<!-- Generated from the PSBI Partner API Postman collection. Do not edit by hand. Interactive version: https://apis.psbi.com -->

# PSBI Partner API

Build background screening and ongoing compliance into your HRIS, ATS or workforce platform.

| Part | What it does |
|---|---|
| **AutoPilot API** | Enroll a person into a position (a *checklist*). PSBI orders everything that position requires and keeps it current. |
| **Partner Gateway** | Order specific background check products for a person. |
| **Records** | Read the people, orders, reports and documents on the account, wherever they were created. |
| **Status Webhooks** | PSBI posts to your endpoint when an order is in review, in progress and complete. |

**New integration?** Start with **Recipe: AutoPilot enrollment with status webhooks**. It walks through a working integration from the first API call to the completed report.

**Using an AI assistant or coding agent?** Point it at the plain-markdown version: [psbi.com/developers/partner-api.md](https://psbi.com/developers/partner-api.md), with the recipe at [psbi.com/developers/autopilot-webhooks-recipe.md](https://psbi.com/developers/autopilot-webhooks-recipe.md). Both are indexed from [psbi.com/llms.txt](https://psbi.com/llms.txt).

## Base URL

```
https://app.psbi.com
```

## Authentication

Every request carries your API key. Send it in a header:

```
X-API-Key: your-api-key
```

`Authorization: Bearer your-api-key` is also accepted.

- A key belongs to one connection, and a connection belongs to one PSBI client account. Every request acts on that account only.
- Keep keys server-side. A key is a credential for your clients' screening data.
- If the system calling PSBI cannot set headers, an `api_key` query parameter is accepted. Prefer a header wherever you can, because URLs end up in logs.

Your PSBI implementation contact issues keys.

## Requests

- Send JSON with `Content-Type: application/json`, and `Accept: application/json`.
- Dates are `YYYY-MM-DD`. Timestamps in responses are ISO 8601 in UTC.

## Responses

Every response has a boolean `success`. Failures add an `error` object:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The provided data is invalid",
    "details": { "consumer.email": ["Consumer email is required"] }
  }
}
```

`details` is included when there is more to say.

| HTTP | `error.code` | Meaning |
|---|---|---|
| 400 | `NO_CLIENT_ASSOCIATION` | The API key is not attached to a client account. Contact PSBI. |
| 401 | `MISSING_API_KEY` | No API key was sent. |
| 401 | `INVALID_API_KEY` | The key is wrong or has been deactivated. |
| 403 | `READ_ACCESS_NOT_ENABLED` | **Records** endpoints are not enabled for this key. Contact PSBI. |
| 403 | `PLATFORM_CONNECTION_NOT_SUPPORTED` | **Records** endpoints need a key issued for one client account. |
| 404 | `TRANSACTION_NOT_FOUND` | No request with that `external_id` exists on your connection. |
| 404 | `CHECKLIST_NOT_FOUND` | The checklist does not exist, is inactive, or belongs to another account. |
| 404 | `CONSUMER_NOT_FOUND` | A transition could not match the person, or a **Records** consumer id does not exist on your account. |
| 404 | `ORDER_NOT_FOUND`, `REPORT_NOT_FOUND`, `DOCUMENT_NOT_FOUND` | That **Records** id does not exist on your account. |
| 404 | `NO_ACTIVE_ENROLLMENT` | A transition found the person, but they are not enrolled in a position. |
| 409 | `DUPLICATE_TRANSACTION` | That `external_id` has already been used on your connection. |
| 422 | `VALIDATION_FAILED` | The body failed validation. `details` lists each field. |
| 422 | varies | A synchronous (`"sync": true`) request was valid but could not be processed, for example `already_enrolled`. Read `error.message`. |
| 429 | `RATE_LIMIT_EXCEEDED` | Too many **Records** requests. Wait for `Retry-After` seconds. |
| 500 | `INTERNAL_ERROR` | Something failed on PSBI's side. Retry later, and contact PSBI if it persists. |

## Identifiers

| Field | Whose | Notes |
|---|---|---|
| `external_id` | **Yours** | Your reference for the request, such as a hire or requisition id. It must be unique per connection, and reusing one returns `409`. You look requests up by it, and it comes back on every status webhook. If you leave it out, PSBI generates one and returns it, but you should always send your own. |
| `transaction_id` | PSBI | The id of your API request in PSBI. |
| `consumer_transaction_id` | PSBI | The id of the background check order the request produced. It is the order `id` in **Records**. |
| `consumer.id` | PSBI | The person's id in PSBI. Use it for transitions. |

## Synchronous and asynchronous processing

- **Asynchronous (default).** PSBI validates the request, returns `202 Accepted` with `status: "received"`, and processes it in the background. Processing normally completes within a minute. Poll the status endpoint or wait for webhooks.
- **Synchronous (`"sync": true`).** PSBI processes the request before responding and returns `200` with the result. Allow up to 30 seconds. This suits interactive screens. For bulk loads, use asynchronous.

Validation errors, unknown checklists and duplicate `external_id`s are rejected immediately in both modes.

## Volume

**Records** endpoints allow 120 requests per minute per key. Other endpoints have no published per-minute limit. Before loading records in bulk (thousands at once), tell your implementation contact.

## Support

Contact your PSBI implementation team, or email integrations@psbi.com.

Step-by-step integration guide: [Recipe: AutoPilot enrollment with status webhooks](./autopilot-webhooks-recipe.md)

---

## Authentication

Check your API key before sending real requests.

### Test Authentication

```http
GET https://app.psbi.com/api/v1/partners/transactions/test-auth-check
X-API-Key: <your API key>
Accept: application/json
```

Confirm your API key works before sending real requests.

This looks up a request id that does not exist, so a working key returns **`404 TRANSACTION_NOT_FOUND`**. That 404 means you are authenticated.

| Response | Meaning |
|---|---|
| `404 TRANSACTION_NOT_FOUND` | The key is valid. |
| `401 INVALID_API_KEY` | The key is wrong or inactive. |
| `401 MISSING_API_KEY` | No key reached PSBI. Check the header name. |

**Example response: Key is valid** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "TRANSACTION_NOT_FOUND",
    "message": "Transaction not found"
  }
}
```

**Example response: Key is invalid** (401 Unauthorized)

```json
{
  "success": false,
  "error": {
    "code": "INVALID_API_KEY",
    "message": "Invalid API key provided"
  }
}
```

**Example response: Key is missing** (401 Unauthorized)

```json
{
  "success": false,
  "error": {
    "code": "MISSING_API_KEY",
    "message": "API key is required. Provide via X-API-Key header or Authorization: Bearer token"
  }
}
```

---

## AutoPilot API

AutoPilot manages a person's compliance for a **position**. You say which position someone holds, and PSBI orders what that position requires, tracks when each requirement expires, and handles changes when the person moves to another position.

### Concepts

- **Checklist (position).** A named set of requirements, such as "Caregiver" or "Driver". Your PSBI implementation contact sets these up and gives you their ids or exact names.
- **Tags.** Extra requirements layered onto a position, such as "DOT".
- **Enrollment.** A person's membership in a position.
- **Order.** The background check PSBI places to satisfy the requirements. This is what status webhooks report on.

### Endpoints

| Endpoint | Use |
|---|---|
| `POST /api/v1/partners/autopilot/enroll` | Put a person in a position. |
| `POST /api/v1/partners/autopilot/transition` | Move a person to a different position. |
| `GET /api/v1/partners/autopilot/enrollment/{external_id}` | Check a request's outcome. |

### Enrolling someone who is already enrolled

- **Asynchronous requests.** If the person is already active in **the same** position, the request is marked `processed` with no new order. If they are active in **a different** position, the enroll is handled as a transition to the new one.
- **Synchronous requests.** The same position is rejected with `422 already_enrolled`.

### Enrollment

Put people into positions.

#### Enroll Consumer (Minimal)

```http
POST https://app.psbi.com/api/v1/partners/autopilot/enroll
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Enroll a person into a position with only the required fields.

Defaults that apply here:

- **Path:** the account default, normally `email_onboarding`, so the person receives an email invitation.
- **Processing:** asynchronous. You get `202 Accepted` straight away.
- **Order:** created, then submitted once the person completes their invitation.

Save the `external_id`. It is how you look the request up and how status webhooks refer to it.

##### Request fields

| Field | Type | Required | Description |
|---|---|---|---|
| `external_id` | string (max 255) | Recommended | Your reference for this request. Unique per connection. |
| `consumer.first_name` | string | **Yes** | |
| `consumer.last_name` | string | **Yes** | |
| `consumer.email` | string | **Yes** | Where the person's invitation goes on email paths. |
| `consumer.phone` | string | No | |
| `consumer.ssn` | string | No | 9 digits. Used to match an existing person. |
| `consumer.date_of_birth` | date | No | `YYYY-MM-DD` |
| `consumer.address`, `consumer.city`, `consumer.state`, `consumer.zip` | string | No | `state` is the two-letter code. |
| `checklist_id` | integer | **One of** | The position to enroll into. |
| `checklist_code` | string | **One of** | The position's exact name, as an alternative to `checklist_id`. |
| `tag_ids` / `tag_codes` | array | No | Additional requirement tags, by id or exact name. Unknown tags are ignored. |
| `enrollment_path` | string | No | How the person is onboarded. See the table below. |
| `create_initial_transaction` | boolean | No | Overrides whether an order is created. By default one is created on every path except `enroll_only`. |
| `sync` | boolean | No | Process before responding. Default `false`. |
| `user_email` | string | No | On synchronous requests, the email of the client user to attribute the order to. |
| `metadata` | object | No | Your own key/value data, stored on the order. |
| `notes` | string (max 2000) | No | Stored with the request. |

##### Enrollment paths

| `enrollment_path` | What happens |
|---|---|
| `email_onboarding` | The person gets an email invitation to fill in their details and give consent. The order is submitted once they finish. |
| `email_consent` | Same invitation flow as `email_onboarding`. |
| `instant` / `manual_entry` | No email to the person. A task is created for the client's team to complete the person's details in PSBI. |
| `enroll_only` | Enrolls the person in the position without ordering anything. No email, no order, no status webhooks. |

If you leave `enrollment_path` out, the account's default is used, which is `email_onboarding` unless PSBI has configured another.

##### Matching an existing person

PSBI looks for the person in your account before creating a new record:

1. **SSN.** An exact match, when `consumer.ssn` is sent, among people with the same last name or date of birth.
2. **Email plus last name.**
3. Otherwise a new person is created.

On a synchronous response, `consumer.created` tells you which happened.

**Request body**

```json
{
  "external_id": "HR-1789500000",
  "consumer": {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com"
  },
  "checklist_code": "Caregiver"
}
```

**Example response: Accepted (async)** (202 Accepted)

```json
{
  "success": true,
  "transaction_id": 1234,
  "external_id": "HR-2026-001234",
  "status": "received",
  "message": "Enrollment request received and queued for processing",
  "received_at": "2026-09-15T14:02:11.000000Z"
}
```

#### Enroll Consumer (Full, Sync)

```http
POST https://app.psbi.com/api/v1/partners/autopilot/enroll
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Enroll with every option set, processed synchronously so the response carries the result.

##### Response fields (sync)

| Field | Description |
|---|---|
| `transaction_id` | PSBI's id for this API request. |
| `enrollment` | The position enrollment: `id`, `checklist_name`, `status`, `enrolled_at`. |
| `consumer` | The person: `id`, `name`, `email`, and `created` (`true` if PSBI created a new record, `false` if it matched an existing one). |
| `transaction` | The background check order: `id` (the `consumer_transaction_id` on webhooks), `status`, `search_count`. Absent when no order was created. |
| `task` | The onboarding task created, if any: `id`, `type`. |
| `pending_requirements` | Present and `true` when the position still has requirements that aren't satisfied yet. |

On email paths the order starts in `draft` with `search_count` 0. Searches are added when the person completes their invitation.

##### Synchronous failures

A valid request that cannot be processed returns `422`. `error.code` names the reason, for example `already_enrolled` when the person is already active in this position. The `external_id` is spent either way, so resend with a new one.

##### Request fields

| Field | Type | Required | Description |
|---|---|---|---|
| `external_id` | string (max 255) | Recommended | Your reference for this request. Unique per connection. |
| `consumer.first_name` | string | **Yes** | |
| `consumer.last_name` | string | **Yes** | |
| `consumer.email` | string | **Yes** | Where the person's invitation goes on email paths. |
| `consumer.phone` | string | No | |
| `consumer.ssn` | string | No | 9 digits. Used to match an existing person. |
| `consumer.date_of_birth` | date | No | `YYYY-MM-DD` |
| `consumer.address`, `consumer.city`, `consumer.state`, `consumer.zip` | string | No | `state` is the two-letter code. |
| `checklist_id` | integer | **One of** | The position to enroll into. |
| `checklist_code` | string | **One of** | The position's exact name, as an alternative to `checklist_id`. |
| `tag_ids` / `tag_codes` | array | No | Additional requirement tags, by id or exact name. Unknown tags are ignored. |
| `enrollment_path` | string | No | How the person is onboarded. See the table below. |
| `create_initial_transaction` | boolean | No | Overrides whether an order is created. By default one is created on every path except `enroll_only`. |
| `consents_collected` | boolean | No | `true` when you collected the person's disclosures and authorization yourself. See below. |
| `sync` | boolean | No | Process before responding. Default `false`. |
| `user_email` | string | No | On synchronous requests, the email of the client user to attribute the order to. |
| `profile` | object | No | Work and education history you already hold. See below. |
| `metadata` | object | No | Your own key/value data, stored on the order. |
| `notes` | string (max 2000) | No | Stored with the request. |

##### Work and education history (`profile`)

Send history you already hold so the person isn't asked for it again. `profile` is keyed by profile type code; field names are that type's fields.

- **`employment`** and **`dot_employment`** hold several entries: send `{"entries": [ {...}, {...} ]}`. Your entries **replace** any the person already has for that type. Each entry becomes its own verification.
- **`education`** holds one entry: send its fields directly, `{"institution_name": "...", ...}`.
- An entry counts as complete when every required field is present. If every entry is complete, the person skips that part of their invitation. If any entry is short, they see your entries pre-filled and are asked for what's missing.
- A malformed `profile` (unknown type code, `entries` on `education`, a flat object on `employment`) returns `422 VALIDATION_FAILED`, and the `external_id` is not spent.

| Type | Required fields | Other fields |
|---|---|---|
| `employment` | `employer_name`, `position`, `start_date`, `current_employer` (boolean) | `end_date`, `department`, `employer_phone`, `employer_city`, `employer_state`, `employer_country`, `do_not_contact` |
| `education` | `institution_name`, `institution_type`, `start_date` | `end_date`, `degree`, `field_of_study`, `minor`, `education_level`, `graduated`, `graduation_date`, `currently_attending`, `institution_city`, `institution_state`, `institution_country` |

Dates are `YYYY-MM-DD`. Your PSBI implementation contact can confirm the fields for any other type.

`education.institution_type` is one of: `High School`, `College`, `University`, `Trade School`, `Technical Institute`, `Graduate School`, `Professional School`, `Online School`, `Other`. `education.education_level` is one of: `High School Diploma`, `Associate's Degree`, `Bachelor's Degree`, `Master's Degree`, `Doctoral Degree`, `Professional Certification`, `Some College`, `Other`. Send them exactly as written; other values aren't recognised.

##### Enrollment paths

| `enrollment_path` | What happens |
|---|---|
| `email_onboarding` | The person gets an email invitation to fill in their details and give consent. The order is submitted once they finish. |
| `email_consent` | Same invitation flow as `email_onboarding`. |
| `instant` / `manual_entry` | No email to the person. A task is created for the client's team to complete the person's details in PSBI. |
| `enroll_only` | Enrolls the person in the position without ordering anything. No email, no order, no status webhooks. |

If you leave `enrollment_path` out, the account's default is used, which is `email_onboarding` unless PSBI has configured another.

##### Consent you collected yourself (`consents_collected`)

Send `"consents_collected": true` when your own sign-up flow already presented PSBI's required disclosures and collected the person's authorization. PSBI then records a certification for each consent the order needs, attributed to your account, and never asks the person for it. Only send it when you hold a signed authorization for this person and can produce it on request.

Consent is the only step it skips. Any personal data the products need and you did not send is still collected.

Pair it with `"enrollment_path": "instant"` for a fully touchless order: when everything the position needs is present, the order is submitted at once. If something is missing, a task goes to your account's team rather than an email to the person.

##### Matching an existing person

PSBI looks for the person in your account before creating a new record:

1. **SSN.** An exact match, when `consumer.ssn` is sent, among people with the same last name or date of birth.
2. **Email plus last name.**
3. Otherwise a new person is created.

On a synchronous response, `consumer.created` tells you which happened.

**Request body**

```json
{
  "external_id": "HR-1789500000",
  "consumer": {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com",
    "phone": "555-123-4567",
    "ssn": "123456789",
    "date_of_birth": "1990-05-15",
    "address": "123 Main Street",
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  },
  "checklist_id": 4,
  "tag_codes": [
    "DOT"
  ],
  "enrollment_path": "email_onboarding",
  "user_email": "hiring.manager@example.com",
  "sync": true,
  "profile": {
    "employment": {
      "entries": [
        {
          "employer_name": "Company A",
          "position": "Driver",
          "start_date": "2021-03-01",
          "end_date": "2024-06-30",
          "current_employer": false
        },
        {
          "employer_name": "Company B",
          "position": "Dispatcher",
          "start_date": "2024-07-15",
          "current_employer": true
        }
      ]
    },
    "education": {
      "institution_name": "State University",
      "institution_type": "University",
      "education_level": "Bachelor's Degree",
      "degree": "BS",
      "start_date": "2014-09-01"
    }
  },
  "metadata": {
    "department": "Nursing",
    "hire_date": "2026-10-01"
  },
  "notes": "New hire for ICU"
}
```

**Example response: Processed (sync)** (200 OK)

```json
{
  "success": true,
  "transaction_id": 1234,
  "external_id": "HR-2026-001234",
  "status": "processed",
  "message": "Consumer onboarding task created. Transaction will be created after task completion.",
  "enrollment": {
    "id": 108,
    "checklist_name": "Caregiver",
    "status": "active",
    "enrolled_at": "2026-09-15T14:02:11.000000Z"
  },
  "consumer": {
    "id": 54321,
    "name": "John Doe",
    "email": "john.doe@example.com",
    "created": true
  },
  "transaction": {
    "id": 98765,
    "status": "draft",
    "search_count": 0
  },
  "task": {
    "id": 9012,
    "type": "Consumer Onboarding"
  },
  "pending_requirements": true
}
```

**Example response: Validation error** (422 Unprocessable Entity)

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The provided data is invalid",
    "details": {
      "consumer.last_name": [
        "Consumer last name is required"
      ],
      "consumer.email": [
        "Consumer email is required"
      ],
      "checklist_id": [
        "Either checklist_id or checklist_code is required"
      ],
      "checklist_code": [
        "Either checklist_id or checklist_code is required"
      ]
    }
  }
}
```

**Example response: Checklist not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "CHECKLIST_NOT_FOUND",
    "message": "Checklist not found or not available",
    "details": {
      "identifier": "Invalid Position"
    }
  }
}
```

**Example response: Duplicate external_id** (409 Conflict)

```json
{
  "success": false,
  "error": {
    "code": "DUPLICATE_TRANSACTION",
    "message": "Transaction with this external ID already exists",
    "details": {
      "external_id": "HR-2026-001234",
      "existing_transaction_id": 1234
    }
  }
}
```

**Example response: Already enrolled (sync)** (422 Unprocessable Entity)

```json
{
  "success": false,
  "transaction_id": 1235,
  "external_id": "HR-2026-001235",
  "error": {
    "code": "already_enrolled",
    "message": "Consumer is already enrolled in Caregiver"
  }
}
```

#### Enroll Consumer (Enroll Only)

```http
POST https://app.psbi.com/api/v1/partners/autopilot/enroll
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Put a person in a position without ordering anything.

Use this to load existing employees whose screening is already on file, or to start tracking a position before a background check is due.

- An enrollment is created, and the position's requirements show as outstanding (`pending_requirements: true`).
- No order is created and no email is sent, so **no status webhooks follow**.
- The response has no `transaction` or `task`.

**Request body**

```json
{
  "external_id": "ENROLL-ONLY-1789500000",
  "consumer": {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com"
  },
  "checklist_code": "Caregiver",
  "enrollment_path": "enroll_only",
  "sync": true
}
```

**Example response: Enrolled without an order** (200 OK)

```json
{
  "success": true,
  "transaction_id": 1236,
  "external_id": "ENROLL-ONLY-12345",
  "status": "processed",
  "message": "Successfully enrolled Jane Smith in Caregiver (no initial transaction)",
  "enrollment": {
    "id": 109,
    "checklist_name": "Caregiver",
    "status": "active",
    "enrolled_at": "2026-09-15T14:02:11.000000Z"
  },
  "consumer": {
    "id": 54322,
    "name": "Jane Smith",
    "email": "jane.smith@example.com",
    "created": true
  },
  "pending_requirements": true
}
```

### Transition

Move people between positions.

#### Transition by Consumer ID

```http
POST https://app.psbi.com/api/v1/partners/autopilot/transition
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Move a person to a new position, identified by PSBI's `consumer_id`.

##### What a transition does

1. Finds the person's active position enrollment.
2. Compares the new position's requirements with what the person already has.
3. Carries over compliance that is still valid (`reused_count`).
4. Orders only what is new (`net_new_count`). If nothing is new, no order is created and no status webhooks follow.
5. Replaces the old enrollment with the new one.

##### Request fields

| Field | Type | Required | Description |
|---|---|---|---|
| `consumer_id` | integer | **One of** | PSBI's id for the person. |
| `consumer` | object | **One of** | Matching data: `ssn`, or `email` **and** `last_name`. |
| `new_checklist_id` / `new_checklist_code` | integer / string | **One of** | The new position. |
| `external_id` | string | Recommended | Your reference for this request. Unique per connection. |
| `sync` | boolean | No | Process before responding. |
| `metadata` | object | No | Your own key/value data. |
| `notes` | string | No | |

Tags are not accepted on transitions.

Whether or not you send `sync`, an unknown person (`CONSUMER_NOT_FOUND`) or position (`CHECKLIST_NOT_FOUND`) is rejected immediately.

**Request body**

```json
{
  "consumer_id": 54321,
  "new_checklist_code": "Mobile Caregiver",
  "external_id": "TRANSITION-1789500000",
  "sync": true
}
```

**Example response: Transitioned (sync)** (200 OK)

```json
{
  "success": true,
  "transaction_id": 1237,
  "external_id": "TRANSITION-001234",
  "status": "processed",
  "message": "Position transition created with 2 new requirements (3 reused)",
  "enrollment": {
    "id": 110,
    "checklist_name": "Mobile Caregiver",
    "status": "active"
  },
  "consumer": {
    "id": 54321,
    "name": "John Doe"
  },
  "analysis": {
    "reused_count": 3,
    "net_new_count": 2
  },
  "transaction": {
    "id": 98770,
    "status": "draft",
    "search_count": 0
  }
}
```

**Example response: No active enrollment** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "NO_ACTIVE_ENROLLMENT",
    "message": "Consumer has no active AutoPilot enrollment",
    "details": {
      "consumer_id": 54321
    }
  }
}
```

#### Transition by Consumer Matching

```http
POST https://app.psbi.com/api/v1/partners/autopilot/transition
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Move a person to a new position when you don't have PSBI's `consumer_id`.

Match on SSN (preferred):

```json
{ "consumer": { "ssn": "123456789" }, "new_checklist_code": "Mobile Caregiver" }
```

Or on email and last name together (email alone never matches):

```json
{ "consumer": { "email": "john.doe@example.com", "last_name": "Doe" }, "new_checklist_code": "Mobile Caregiver" }
```

##### What a transition does

1. Finds the person's active position enrollment.
2. Compares the new position's requirements with what the person already has.
3. Carries over compliance that is still valid (`reused_count`).
4. Orders only what is new (`net_new_count`). If nothing is new, no order is created and no status webhooks follow.
5. Replaces the old enrollment with the new one.

##### Request fields

| Field | Type | Required | Description |
|---|---|---|---|
| `consumer_id` | integer | **One of** | PSBI's id for the person. |
| `consumer` | object | **One of** | Matching data: `ssn`, or `email` **and** `last_name`. |
| `new_checklist_id` / `new_checklist_code` | integer / string | **One of** | The new position. |
| `external_id` | string | Recommended | Your reference for this request. Unique per connection. |
| `sync` | boolean | No | Process before responding. |
| `metadata` | object | No | Your own key/value data. |
| `notes` | string | No | |

Tags are not accepted on transitions.

Whether or not you send `sync`, an unknown person (`CONSUMER_NOT_FOUND`) or position (`CHECKLIST_NOT_FOUND`) is rejected immediately.

**Request body**

```json
{
  "consumer": {
    "ssn": "123456789"
  },
  "new_checklist_id": 11,
  "external_id": "TRANSITION-SSN-1789500000"
}
```

**Example response: Accepted (async)** (202 Accepted)

```json
{
  "success": true,
  "transaction_id": 1238,
  "external_id": "TRANSITION-SSN-001234",
  "status": "received",
  "message": "Transition request received and queued for processing",
  "received_at": "2026-09-15T14:02:11.000000Z"
}
```

**Example response: Consumer not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "CONSUMER_NOT_FOUND",
    "message": "Consumer not found",
    "details": {
      "identifiers": [
        "email",
        "last_name"
      ]
    }
  }
}
```

### Unenroll

End a person's enrollment when they leave.

#### Unenroll (Person Left)

```http
POST https://app.psbi.com/api/v1/partners/autopilot/unenroll
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

End a person's position enrollment when they leave your program, so PSBI stops renewing their checks and ends any ongoing monitoring.

##### Modes

| `mode` | What happens |
|---|---|
| `deactivate` (default) | The enrollment is marked inactive. Its history stays in PSBI, renewals stop and monitoring ends. |
| `remove` | The enrollment is deleted, so the person can later be enrolled again from scratch. Their completed screening history is kept. |

Orders already in progress are not cancelled by either mode.

##### Request fields

| Field | Type | Required | Description |
|---|---|---|---|
| `consumer_id` | integer | **One of** | PSBI's id for the person. |
| `consumer` | object | **One of** | Matching data: `ssn` (with `last_name` or `date_of_birth`), or `email` **and** `last_name`. |
| `checklist_id` / `checklist_code` | integer / string | No | Limit to one position. Without it, every live enrollment the person has is ended. |
| `mode` | string | No | `deactivate` (default) or `remove`. |

Processed immediately. A repeat request finds nothing live and returns `404 NO_ACTIVE_ENROLLMENT`, so retrying is safe.

**Request body**

```json
{
  "consumer": {
    "ssn": "123-45-6789",
    "last_name": "Doe"
  },
  "mode": "deactivate"
}
```

**Example response: Deactivated** (202 Accepted)

```json
{
  "success": true,
  "status": "processed",
  "message": "Enrollment marked inactive. Renewals and monitoring have stopped.",
  "mode": "deactivate",
  "consumer": {
    "id": 54321,
    "name": "John Doe"
  },
  "enrollments": [
    {
      "enrollment_id": 9876,
      "checklist_id": 42,
      "status": "inactive"
    }
  ]
}
```

### Status

Check the outcome of enroll and transition requests.

#### Get Enrollment Status

```http
GET https://app.psbi.com/api/v1/partners/autopilot/enrollment/{external_id}
X-API-Key: <your API key>
Accept: application/json
```

Look up an enroll or transition request by your `external_id`.

##### Response

| Field | Description |
|---|---|
| `transaction.status` | Processing of **your request**: `received`, then `processing`, then `processed` or `failed`. |
| `transaction.error_message` | Why a request `failed`. |
| `transaction.type` | `autopilot_enroll` or `autopilot_transition`. |
| `enrollment` | Present once the request is processed: `id`, `consumer_id`, `consumer_created`, `search_count`, `pending_requirements`. |
| `transition_analysis` | Transitions only: `reused_count`, `net_new_count`. |
| `consumer_transaction` | The background check order, if one was created: `id`, `status`, `submitted_at`, `consumer`. |

`consumer_transaction.status` is the order's own status:

| Order status | Meaning |
|---|---|
| `draft` | Waiting on the person's invitation or the client's task. |
| `review` | Information collected, waiting for the order to be submitted. |
| `processing` | Submitted; searches are in progress. |
| `complete` | Every search is finished. |
| `cancelled` | Cancelled. |

A request that was `processed` without a `consumer_transaction` either created no order (`enroll_only`, or a transition with nothing new) or found the person already active in that position.

You don't need to poll this endpoint once status webhooks are set up. It is useful for confirming a request was processed, and for reconciling after an outage.

**Example response: Processed** (200 OK)

```json
{
  "success": true,
  "transaction": {
    "id": 1234,
    "external_id": "HR-2026-001234",
    "type": "autopilot_enroll",
    "status": "processed",
    "received_at": "2026-09-15T14:02:11.000000Z",
    "processed_at": "2026-09-15T14:02:43.000000Z",
    "error_message": null
  },
  "enrollment": {
    "id": 108,
    "consumer_id": 54321,
    "consumer_created": true,
    "search_count": 0,
    "pending_requirements": true
  },
  "consumer_transaction": {
    "id": 98765,
    "status": "draft",
    "submitted_at": null,
    "consumer": {
      "id": 54321,
      "name": "John Doe"
    }
  }
}
```

**Example response: Failed** (200 OK)

```json
{
  "success": true,
  "transaction": {
    "id": 1299,
    "external_id": "HR-2026-001299",
    "type": "autopilot_enroll",
    "status": "failed",
    "received_at": "2026-09-15T14:02:11.000000Z",
    "processed_at": "2026-09-15T14:02:43.000000Z",
    "error_message": "Enrollment created but transaction failed: No requirements found for this checklist"
  }
}
```

**Example response: Not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "TRANSACTION_NOT_FOUND",
    "message": "Transaction not found",
    "details": {
      "external_id": "UNKNOWN-ID"
    }
  }
}
```

---

## Partner Gateway

Order specific background check products for a person.

| `transaction_type` | Who completes the person's information |
|---|---|
| `consumer` (default) | The person, through an email invitation. |
| `client` | The client's team, through a task in PSBI. |

### Flow

1. **Create** the transaction with your `external_id`.
2. The person or the client's team **completes** the information. The order then moves to review and is submitted.
3. **Receive status webhooks**, or poll **Get Transaction Status**.
4. When the order is **complete**, the report-ready webhook carries a link to download the report.

To enroll people into positions and keep them compliant over time, use the **AutoPilot API** instead.

### Create Consumer Transaction

```http
POST https://app.psbi.com/api/v1/partners/transactions
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Order background check products for a person, who receives an email invitation to complete their details and give consent.

`transaction_type: "consumer"` is the default. Processing is asynchronous unless you send `"sync": true`.

Status webhooks work the same for Gateway orders as for AutoPilot orders.

#### Request fields

| Field | Type | Required | Description |
|---|---|---|---|
| `external_id` | string (max 255) | Recommended | Your reference. Unique per connection; reusing it returns `409`. |
| `transaction_type` | string | No | `consumer` (default) or `client`. |
| `consumer.first_name`, `consumer.last_name` | string | **Yes** | |
| `consumer.email` | string | **Yes** | |
| `consumer.phone`, `consumer.ssn`, `consumer.date_of_birth` | string / date | No | |
| `consumer.address`, `consumer.city`, `consumer.state`, `consumer.zip` | string | No | |
| `products` | array | **Yes** | At least one product. See below. |
| `profile` | object | No | Product-specific data keyed by profile type code, for example `{"mvr": {"license_state": "TX", "license_number": "D1234567"}}`. |
| `consents_collected` | boolean | No | `true` when you collected the person's disclosures and authorization yourself. See below. |
| `metadata` | object | No | Your own key/value data, such as `employee_id` or `notes`. |
| `sync` | boolean | No | Process before responding. Default `false`. |

#### Consent you collected yourself (`consents_collected`)

Send `"consents_collected": true` when your own sign-up flow already presented PSBI's required disclosures and collected the person's authorization. PSBI then records a certification for each consent the order needs, attributed to your account, and never asks the person for it. Only send it when you hold a signed authorization for this person and can produce it on request.

Consent is the only step it skips. Any personal data the products need and you did not send is still collected.

#### Products

Each entry in `products` can be any of these:

- a product name: `"County Criminal"`
- a PSBI product id: `12`
- an object: `{ "product_id": 12 }`, `{ "product_name": "County Criminal" }`, `{ "order_template_id": 3 }` or `{ "order_template_name": "Standard Package" }`

Names are matched against your account's products, then its order templates (packages). If nothing matches, your account's default products are ordered rather than the request failing. Confirm the exact product and package names with your implementation contact before you go live.

**Request body**

```json
{
  "external_id": "CONSUMER_1789500000",
  "transaction_type": "consumer",
  "consumer": {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com",
    "phone": "555-123-4567",
    "date_of_birth": "1990-01-15",
    "address": "456 Oak Avenue",
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  },
  "products": ["Statewide Criminal", "Sex Offender Registry"],
  "metadata": {
    "employee_id": "E-10442",
    "notes": "Pre-hire screening"
  }
}
```

**Example response: Accepted (async)** (202 Accepted)

```json
{
  "success": true,
  "transaction_id": 5114,
  "external_id": "CONSUMER_12345",
  "status": "received",
  "message": "Transaction received and queued for processing",
  "received_at": "2026-09-15T14:02:11.000000Z",
  "estimated_completion": "2026-09-18T14:02:11.000000Z",
  "tracking_url": null,
  "consumer": {
    "id": null,
    "name": "John Doe",
    "email": "john.doe@example.com"
  }
}
```

**Example response: Processed (sync)** (200 OK)

```json
{
  "success": true,
  "transaction_id": 5114,
  "external_id": "CONSUMER_12345",
  "status": "processed",
  "message": "Transaction processed successfully",
  "received_at": "2026-09-15T14:02:11.000000Z",
  "estimated_completion": "2026-09-18T14:02:11.000000Z",
  "tracking_url": null,
  "consumer": {
    "id": 54321,
    "name": "John Doe",
    "email": "john.doe@example.com"
  }
}
```

**Example response: Validation error** (422 Unprocessable Entity)

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The provided data is invalid",
    "details": {
      "consumer.last_name": [
        "Consumer last name is required"
      ],
      "consumer.email": [
        "Consumer email is required"
      ],
      "products": [
        "At least one product or service is required"
      ]
    }
  }
}
```

**Example response: Duplicate external_id** (409 Conflict)

```json
{
  "success": false,
  "error": {
    "code": "DUPLICATE_TRANSACTION",
    "message": "This event has already been processed",
    "details": {
      "external_id": "CONSUMER_12345",
      "original_transaction_id": 5114,
      "original_status": "processed"
    }
  }
}
```

### Create Client Transaction

```http
POST https://app.psbi.com/api/v1/partners/transactions
X-API-Key: <your API key>
Content-Type: application/json
Accept: application/json
```

Order background check products without emailing the person. A task is created for the client's team to complete the person's details in PSBI.

Use this when your client collects the information and consent themselves.

The body is the same as **Create Consumer Transaction**, with `transaction_type` set to `"client"`.

**Request body**

```json
{
  "external_id": "CLIENT_1789500000",
  "transaction_type": "client",
  "consumer": {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane.doe@example.com",
    "ssn": "123456789",
    "date_of_birth": "1985-03-22",
    "state": "TX"
  },
  "products": ["County Criminal", "National Criminal"]
}
```

**Example response: Accepted (async)** (202 Accepted)

```json
{
  "success": true,
  "transaction_id": 5115,
  "external_id": "CLIENT_67890",
  "status": "received",
  "message": "Transaction received and queued for processing",
  "received_at": "2026-09-15T14:02:11.000000Z",
  "estimated_completion": "2026-09-18T14:02:11.000000Z",
  "tracking_url": null,
  "consumer": {
    "id": null,
    "name": "Jane Smith",
    "email": "jane.smith@example.com"
  }
}
```

### Get Transaction Status

```http
GET https://app.psbi.com/api/v1/partners/transactions/{external_id}
X-API-Key: <your API key>
Accept: application/json
```

Look up any request on your connection by `external_id`. This covers Gateway orders and AutoPilot enrollments.

#### Response

| Field | Description |
|---|---|
| `transaction.id` | PSBI's request id, prefixed `PTX-`. |
| `transaction.status` | Processing of your request: `received`, `processing`, `processed` or `failed`. |
| `transaction.error_message` | Why a request `failed`. |
| `transaction.consumer_transaction` | The order, once created: `id`, `status` (`draft`, `review`, `processing`, `complete`, `cancelled`), `submitted_at`, `consumer`. |
| `transaction.status_updates` | The five most recent status webhooks PSBI attempted for this request: `event`, `status` (`sent`, `failed`, `retrying` or `pending`), `timestamp`, `delivered_at`. Useful for confirming your endpoint received them. |

**Example response: In progress** (200 OK)

```json
{
  "success": true,
  "transaction": {
    "id": "PTX-5114",
    "external_id": "CONSUMER_12345",
    "status": "processed",
    "transaction_type": "consumer",
    "received_at": "2026-09-15T14:02:11.000000Z",
    "processed_at": "2026-09-15T14:02:43.000000Z",
    "error_message": null,
    "consumer_transaction": {
      "id": 98765,
      "status": "processing",
      "submitted_at": "2026-09-16T09:12:40.000000Z",
      "consumer": {
        "id": 54321,
        "name": "John Doe"
      }
    },
    "status_updates": [
      {
        "event": "consumer.transaction.submitted",
        "status": "sent",
        "timestamp": "2026-09-16T09:12:41.000000Z",
        "delivered_at": "2026-09-16T09:12:41.000000Z"
      },
      {
        "event": "consumer.transaction.review",
        "status": "sent",
        "timestamp": "2026-09-16T09:10:02.000000Z",
        "delivered_at": "2026-09-16T09:10:02.000000Z"
      }
    ]
  }
}
```

**Example response: Not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "TRANSACTION_NOT_FOUND",
    "message": "Transaction not found"
  }
}
```

---

## Records

Read the people, orders and files on your account, including those created in the PSBI portal, renewals, and orders placed by other means. The AutoPilot and Gateway status endpoints only find requests by your own `external_id`; these endpoints use PSBI's ids and list everything on the account.

**Read access is enabled per API key.** Ask your PSBI implementation contact to turn it on. Until then these endpoints return `403 READ_ACCESS_NOT_ENABLED`.

### Keeping in sync

1. **Backfill** by paging through **List Consumers** and **List Orders**.
2. **Stay current** by polling with `updated_since` set to the time of your last successful poll, or react to **Status Webhooks**: each one carries `consumer_transaction_id`, which is the order `id` here.
3. **Collect files** from **Order Documents** when an order is `complete`.

### Ids

| Field | Meaning |
|---|---|
| `consumer.id` | The person in PSBI. Same id as AutoPilot's `consumer_id`. |
| `order.id` | The background check order. Same id as `consumer_transaction_id` on status webhooks and on the Gateway status response. |
| `order.external_id` | Your `external_id`, when the order came from your API key. `null` for orders created any other way. |

An id that does not exist and one that belongs to another account answer the same `404`.

### Limits

120 requests per minute per API key. Over the limit returns `429` with a `Retry-After` header.

### List Consumers

```http
GET https://app.psbi.com/api/v1/partners/consumers?updated_since=2026-09-15T00:00:00Z&active=true&email=john.doe@example.com&page=1&per_page=25
X-API-Key: <your API key>
Accept: application/json
```

People on your account, oldest first.

Paginated: `page` (default 1) and `per_page` (default 25, max 100). The response carries `pagination` with `page`, `per_page`, `total` and `last_page`.

#### Filters

| Parameter | Description |
|---|---|
| `email` | Exact email address. |
| `active` | `true` for people with an active enrollment, `false` for everyone else. |
| `updated_since` | ISO 8601 timestamp. People whose record, orders or enrollments changed at or after this time. |

#### Response

`consumers[]`: `id`, `first_name`, `middle_name`, `last_name`, `email`, `phone`, `active`, `organization` (`id`, `name`, or `null`), `created_at`, `updated_at`.

Social Security numbers and dates of birth are never returned.

**Example response: Consumers** (200 OK)

```json
{
  "success": true,
  "consumers": [
    {
      "id": 54321,
      "first_name": "John",
      "middle_name": null,
      "last_name": "Doe",
      "email": "john.doe@example.com",
      "phone": "(555) 555-0100",
      "active": true,
      "organization": {
        "id": 12,
        "name": "West Region"
      },
      "created_at": "2026-09-15T14:02:43.000000Z",
      "updated_at": "2026-09-18T10:20:01.000000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 25,
    "total": 1,
    "last_page": 1
  }
}
```

**Example response: Read access not enabled** (403 Forbidden)

```json
{
  "success": false,
  "error": {
    "code": "READ_ACCESS_NOT_ENABLED",
    "message": "Read access is not enabled for this API key"
  }
}
```

### Get Consumer

```http
GET https://app.psbi.com/api/v1/partners/consumers/{{consumer_id}}
X-API-Key: <your API key>
Accept: application/json
```

One person, with where they stand.

#### Response

Everything in **List Consumers**, plus:

| Field | Description |
|---|---|
| `enrollments[]` | Each position (checklist) the person is enrolled in: `checklist` (`id`, `name`), `status` (`active`, `paused`, `completed`, `superseded`, `inactive`), `compliance_status` (`complete`, `in_progress`, `pending_review`, `attention_needed`, `incomplete`, `adverse_action`), `enrolled_at`, and `requirements[]`. |
| `enrollments[].requirements[]` | `name`, `status` (`never_completed`, `in_progress`, `pending_review`, `current`, `expiring_soon`, `expired`, `non_compliant`, `skipped`), `completed_at`, `expires_at`, `order_id`, `search_id`. |
| `orders[]` | Every order for the person, newest first: `id`, `status`, `submitted_at`. |

**Example response: Consumer** (200 OK)

```json
{
  "success": true,
  "consumer": {
    "id": 54321,
    "first_name": "John",
    "middle_name": null,
    "last_name": "Doe",
    "email": "john.doe@example.com",
    "phone": "(555) 555-0100",
    "active": true,
    "organization": {
      "id": 12,
      "name": "West Region"
    },
    "created_at": "2026-09-15T14:02:43.000000Z",
    "updated_at": "2026-09-18T10:20:01.000000Z",
    "enrollments": [
      {
        "id": 3064,
        "checklist": {
          "id": 39,
          "name": "Warehouse Associate"
        },
        "status": "active",
        "compliance_status": "in_progress",
        "enrolled_at": "2026-09-15T14:02:43.000000Z",
        "requirements": [
          {
            "name": "County Criminal",
            "status": "current",
            "completed_at": "2026-09-17T16:01:12.000000Z",
            "expires_at": "2027-09-17T16:01:12.000000Z",
            "order_id": 98765,
            "search_id": 4434
          },
          {
            "name": "MVR",
            "status": "in_progress",
            "completed_at": null,
            "expires_at": null,
            "order_id": 98765,
            "search_id": 4436
          }
        ]
      }
    ],
    "orders": [
      {
        "id": 98765,
        "status": "processing",
        "submitted_at": "2026-09-16T09:12:40.000000Z"
      }
    ]
  }
}
```

**Example response: Not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "CONSUMER_NOT_FOUND",
    "message": "Consumer not found",
    "details": {
      "id": 999999
    }
  }
}
```

### Consumer Documents

```http
GET https://app.psbi.com/api/v1/partners/consumers/{{consumer_id}}/documents
X-API-Key: <your API key>
Accept: application/json
```

Reports and files on record, in one list. Each entry has a `kind`:

| `kind` | What it is |
|---|---|
| `report` | A completed PSBI report PDF. `type` is the report type (`background_check`, `credit_report`, `form_i9`, `training`, …) and `is_fcra` says whether it is a consumer report under the FCRA. |
| `document` | A file shared with your account: signed disclosures and consents, and documents the person uploaded. `type` is the document type. |

Only what your team can see in the PSBI portal is listed. Fetch a file with its `download_path`.

Covers every order for the person.

**Example response: Documents** (200 OK)

```json
{
  "success": true,
  "documents": [
    {
      "id": 405,
      "kind": "report",
      "name": "Background Check Report - John Doe - Sep 18, 2026",
      "type": "background_check",
      "is_fcra": true,
      "mime_type": "application/pdf",
      "size": null,
      "order_id": 98765,
      "created_at": "2026-09-18T10:20:01.000000Z",
      "download_path": "/api/v1/partners/reports/405/download"
    },
    {
      "id": 2058,
      "kind": "document",
      "name": "Disclosure and Authorization.pdf",
      "type": "consumer_document",
      "mime_type": "application/pdf",
      "size": 6082,
      "order_id": 98765,
      "created_at": "2026-09-15T14:10:25.000000Z",
      "download_path": "/api/v1/partners/documents/2058/download"
    }
  ]
}
```

### List Orders

```http
GET https://app.psbi.com/api/v1/partners/orders?updated_since=2026-09-15T00:00:00Z&status=complete&consumer_id={{consumer_id}}&submitted_from=2026-09-01T00:00:00Z&submitted_to=2026-09-30T23:59:59Z&page=1&per_page=25
X-API-Key: <your API key>
Accept: application/json
```

Orders on your account, oldest first.

Paginated: `page` (default 1) and `per_page` (default 25, max 100). The response carries `pagination` with `page`, `per_page`, `total` and `last_page`.

#### Filters

| Parameter | Description |
|---|---|
| `consumer_id` | One person's orders. |
| `status` | `draft` (waiting on the person or your team to complete their information), `review`, `processing`, `complete` or `cancelled`. |
| `updated_since` | ISO 8601 timestamp. Orders that changed, or had a search change, at or after this time. |
| `submitted_from`, `submitted_to` | ISO 8601 timestamps bounding `submitted_at`. |

#### Response

`orders[]`: `id`, `external_id`, `consumer` (`id`, `name`), `organization`, `status`, `source`, `submitted_at`, `eta`, `created_at`, `updated_at`.

**Example response: Orders** (200 OK)

```json
{
  "success": true,
  "orders": [
    {
      "id": 98765,
      "external_id": "CONSUMER_12345",
      "consumer": {
        "id": 54321,
        "name": "John Doe"
      },
      "organization": {
        "id": 12,
        "name": "West Region"
      },
      "status": "complete",
      "source": "partner_api",
      "submitted_at": "2026-09-16T09:12:40.000000Z",
      "eta": "2026-09-18T09:12:40.000000Z",
      "created_at": "2026-09-15T14:02:43.000000Z",
      "updated_at": "2026-09-18T10:20:01.000000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 25,
    "total": 1,
    "last_page": 1
  }
}
```

### Get Order

```http
GET https://app.psbi.com/api/v1/partners/orders/{{order_id}}
X-API-Key: <your API key>
Accept: application/json
```

One order and its searches.

#### Response

Everything in **List Orders**, plus:

| Field | Description |
|---|---|
| `requires_review` | `true` when any search returned a result your team should review before deciding. |
| `searches[]` | `id`, `product` (`id`, `name`), `search_detail` (the jurisdiction or scope, such as a county), `status`, `completed`, `completed_at`, `result`, `requires_review`. |
| `searches[].result` | `null` until the search finishes, then a stable key such as `no_record` or `record`. |

Record details are in the report PDF, not in this response.

**Example response: Order** (200 OK)

```json
{
  "success": true,
  "order": {
    "id": 98765,
    "external_id": "CONSUMER_12345",
    "consumer": {
      "id": 54321,
      "name": "John Doe"
    },
    "organization": {
      "id": 12,
      "name": "West Region"
    },
    "status": "complete",
    "source": "partner_api",
    "submitted_at": "2026-09-16T09:12:40.000000Z",
    "eta": "2026-09-18T09:12:40.000000Z",
    "created_at": "2026-09-15T14:02:43.000000Z",
    "updated_at": "2026-09-18T10:20:01.000000Z",
    "requires_review": true,
    "searches": [
      {
        "id": 4434,
        "product": {
          "id": 1,
          "name": "County Criminal"
        },
        "search_detail": "Durham County, NC",
        "status": "Complete",
        "completed": true,
        "completed_at": "2026-09-17T16:01:12.000000Z",
        "result": "record",
        "requires_review": true
      },
      {
        "id": 4435,
        "product": {
          "id": 3,
          "name": "National Criminal"
        },
        "search_detail": null,
        "status": "Complete",
        "completed": true,
        "completed_at": "2026-09-16T11:40:02.000000Z",
        "result": "no_record",
        "requires_review": false
      }
    ]
  }
}
```

**Example response: Not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "ORDER_NOT_FOUND",
    "message": "Order not found",
    "details": {
      "id": 999999
    }
  }
}
```

### Order Documents

```http
GET https://app.psbi.com/api/v1/partners/orders/{{order_id}}/documents
X-API-Key: <your API key>
Accept: application/json
```

Reports and files on record, in one list. Each entry has a `kind`:

| `kind` | What it is |
|---|---|
| `report` | A completed PSBI report PDF. `type` is the report type (`background_check`, `credit_report`, `form_i9`, `training`, …) and `is_fcra` says whether it is a consumer report under the FCRA. |
| `document` | A file shared with your account: signed disclosures and consents, and documents the person uploaded. `type` is the document type. |

Only what your team can see in the PSBI portal is listed. Fetch a file with its `download_path`.

Covers this order only.

**Example response: Documents** (200 OK)

```json
{
  "success": true,
  "documents": [
    {
      "id": 405,
      "kind": "report",
      "name": "Background Check Report - John Doe - Sep 18, 2026",
      "type": "background_check",
      "is_fcra": true,
      "mime_type": "application/pdf",
      "size": null,
      "order_id": 98765,
      "created_at": "2026-09-18T10:20:01.000000Z",
      "download_path": "/api/v1/partners/reports/405/download"
    },
    {
      "id": 2058,
      "kind": "document",
      "name": "Disclosure and Authorization.pdf",
      "type": "consumer_document",
      "mime_type": "application/pdf",
      "size": 6082,
      "order_id": 98765,
      "created_at": "2026-09-15T14:10:25.000000Z",
      "download_path": "/api/v1/partners/documents/2058/download"
    }
  ]
}
```

### Download Report

```http
GET https://app.psbi.com/api/v1/partners/reports/{{report_id}}/download
X-API-Key: <your API key>
Accept: application/json
```

A short-lived link to a report PDF. Use the `download_path` from a documents list.

The response carries the link rather than redirecting to it: fetch `download.url` with a plain GET and **no** `X-API-Key` or `Authorization` header. The link expires at `download.expires_at`, ten minutes after it is issued; request a new one rather than storing it.

Every link issued is recorded against your API key.

**Example response: Link** (200 OK)

```json
{
  "success": true,
  "download": {
    "url": "https://stprdp51.blob.core.windows.net/documents/reports/405.pdf?se=2026-09-18T10%3A30%3A01Z&sp=r&sig=...",
    "expires_at": "2026-09-18T10:30:01.000000Z",
    "filename": "background-check-report-john-doe-sep-18-2026.pdf",
    "mime_type": "application/pdf"
  }
}
```

**Example response: Not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "REPORT_NOT_FOUND",
    "message": "Report not found",
    "details": {
      "id": 999999
    }
  }
}
```

### Download Document

```http
GET https://app.psbi.com/api/v1/partners/documents/{{document_id}}/download
X-API-Key: <your API key>
Accept: application/json
```

A short-lived link to a document. Works exactly like **Download Report**.

**Example response: Link** (200 OK)

```json
{
  "success": true,
  "download": {
    "url": "https://stprdp51.blob.core.windows.net/documents/consumer_documents/2058.pdf?se=2026-09-18T10%3A30%3A01Z&sp=r&sig=...",
    "expires_at": "2026-09-18T10:30:01.000000Z",
    "filename": "Disclosure and Authorization.pdf",
    "mime_type": "application/pdf"
  }
}
```

**Example response: Not found** (404 Not Found)

```json
{
  "success": false,
  "error": {
    "code": "DOCUMENT_NOT_FOUND",
    "message": "Document not found",
    "details": {
      "id": 999999
    }
  }
}
```

---

## Status Webhooks

PSBI posts to your HTTPS endpoint as an order moves through screening. These are **requests PSBI sends to you**, not endpoints you call.

### Turning them on

Send your implementation contact:

- the HTTPS URL that should receive webhooks.

PSBI registers it on your connection and gives you a **signing secret**. Webhooks then cover every order created through your API key, from both AutoPilot enrollments and Gateway transactions.

### Events

| `event` | `status` | When |
|---|---|---|
| `consumer.transaction.review` | `review` | The person's information is collected and the order is waiting to be submitted. **This is not a results review.** On accounts that submit automatically, `in_progress` follows within seconds. |
| `consumer.transaction.submitted` | `in_progress` | The order is submitted and searches are running. |
| `consumer.transaction.completed` | `complete` | Every search is finished. `result` says whether anything needs review. |
| `consumer.transaction.report_generated` | `complete` | The report PDF is ready. `report_url` links to it. This usually follows `completed` within minutes. |

### Body

Every event has the same shape. **All values are strings.**

| Field | Description |
|---|---|
| `event` | The event name from the table above. |
| `status` | `review`, `in_progress` or `complete`. |
| `external_id` | The `external_id` you sent when you enrolled or ordered. |
| `consumer_transaction_id` | PSBI's order id. |
| `consumer.id`, `consumer.first_name`, `consumer.last_name` | The person. |
| `portal_url` | Where a client user views this in PSBI (a PSBI login is required). On `review` it opens the order's submit page. |
| `occurred_at` | When PSBI generated the webhook (UTC). |
| `result` | `completed` and `report_generated` only. `clear` means no search needs review. `needs_review` means at least one search returned something to review, such as a record found or a verification that could not be confirmed. Adjudication happens in PSBI. |
| `report_url` | `report_generated` only. A download link for the report PDF. It is a consumer report, so fetch and store it securely straight away rather than keeping the link. |

```json
{
  "event": "consumer.transaction.completed",
  "status": "complete",
  "external_id": "HR-2026-001234",
  "consumer_transaction_id": "98765",
  "consumer": {
    "id": "54321",
    "first_name": "John",
    "last_name": "Doe"
  },
  "portal_url": "https://app.psbi.com/client/autopilot/consumers/54321",
  "occurred_at": "2026-09-15T18:22:31Z",
  "result": "needs_review"
}
```

### Headers

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `P51-Partner-Gateway/1.0` |
| `X-P51-Event` | The event name. |
| `X-P51-Timestamp` | When the request was sent (ISO 8601). |
| `X-P51-Signature` | Lowercase hex HMAC-SHA256 of the **raw request body**, keyed with your signing secret. |

### Verifying the signature

Compute the HMAC over the exact bytes you received, before parsing the JSON, and compare it in constant time. Reject anything that does not match.

```js
const crypto = require('crypto');

// Express: app.post('/psbi/webhooks', express.raw({ type: 'application/json' }), handler)
function isFromPsbi(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = String(signatureHeader || '').replace(/^sha256=/, '');
  return received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
```

```php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $secret);

if (! hash_equals($expected, $_SERVER['HTTP_X_P51_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
```

### Responding

- Return any **2xx** within **30 seconds**. Acknowledge first and do slow work afterwards.
- A timeout or any non-2xx counts as a failed delivery. PSBI tries **3 times in total**, about a minute apart.

### Designing your receiver

- **Duplicates happen.** A retry after a slow response can deliver the same event twice. Deduplicate on `external_id` + `event` + `occurred_at`, or make your updates idempotent.
- **Order is not guaranteed.** Don't move a record backwards, for example from `complete` to `in_progress`.
- **Not every order sends every event.** `review` can be followed immediately by `in_progress`. An order that is reopened and completed again sends `completed` and `report_generated` again; treat the latest one as current.
- **What sends no webhooks:**
  - `enroll_only` enrollments
  - a transition with no new requirements
  - an enroll that found the person already active in that position
  - orders PSBI creates later on its own, such as renewals
- **Missed one?** **Get Enrollment Status** and **Get Transaction Status** return the order's current status, so you can reconcile.

### Review

```http
POST https://your-app.example.com/psbi/webhooks
```

**Sent by PSBI to your endpoint.**

The person's information is collected and the order is waiting to be submitted, either by the client's team at `portal_url` or automatically. It is not a results review; nothing has been searched yet.

Suggested handling: mark the record as waiting to be submitted.

**Headers**

- `Content-Type: application/json`
- `User-Agent: P51-Partner-Gateway/1.0`
- `X-P51-Event: consumer.transaction.review`
- `X-P51-Timestamp: 2026-09-15T18:22:31.000000Z`
- `X-P51-Signature: 3f1c9a0e7b…`

**Body**

```json
{
  "event": "consumer.transaction.review",
  "status": "review",
  "external_id": "HR-2026-001234",
  "consumer_transaction_id": "98765",
  "consumer": {
    "id": "54321",
    "first_name": "John",
    "last_name": "Doe"
  },
  "portal_url": "https://app.psbi.com/client/consumers/54321/transactions/98765/review",
  "occurred_at": "2026-09-15T18:22:31Z"
}
```

### In Progress

```http
POST https://your-app.example.com/psbi/webhooks
```

**Sent by PSBI to your endpoint.**

The order is submitted and searches are running. Turnaround depends on the searches; county courts can take several business days.

Suggested handling: mark the record as screening in progress.

**Headers**

- `Content-Type: application/json`
- `User-Agent: P51-Partner-Gateway/1.0`
- `X-P51-Event: consumer.transaction.submitted`
- `X-P51-Timestamp: 2026-09-15T18:22:31.000000Z`
- `X-P51-Signature: 3f1c9a0e7b…`

**Body**

```json
{
  "event": "consumer.transaction.submitted",
  "status": "in_progress",
  "external_id": "HR-2026-001234",
  "consumer_transaction_id": "98765",
  "consumer": {
    "id": "54321",
    "first_name": "John",
    "last_name": "Doe"
  },
  "portal_url": "https://app.psbi.com/client/autopilot/consumers/54321",
  "occurred_at": "2026-09-15T18:22:31Z"
}
```

### Complete

```http
POST https://your-app.example.com/psbi/webhooks
```

**Sent by PSBI to your endpoint.**

Every search is finished.

- `result: "clear"`: nothing needs review.
- `result: "needs_review"`: at least one search returned something to review. The client adjudicates in PSBI at `portal_url`. Do not treat `needs_review` as a failed or adverse decision.

The report PDF follows in **Report Ready**.

**Headers**

- `Content-Type: application/json`
- `User-Agent: P51-Partner-Gateway/1.0`
- `X-P51-Event: consumer.transaction.completed`
- `X-P51-Timestamp: 2026-09-15T18:22:31.000000Z`
- `X-P51-Signature: 3f1c9a0e7b…`

**Body**

```json
{
  "event": "consumer.transaction.completed",
  "status": "complete",
  "external_id": "HR-2026-001234",
  "consumer_transaction_id": "98765",
  "consumer": {
    "id": "54321",
    "first_name": "John",
    "last_name": "Doe"
  },
  "portal_url": "https://app.psbi.com/client/autopilot/consumers/54321",
  "occurred_at": "2026-09-15T18:22:31Z",
  "result": "needs_review"
}
```

### Report Ready

```http
POST https://your-app.example.com/psbi/webhooks
```

**Sent by PSBI to your endpoint.**

The report PDF is generated. Download it from `report_url` straight away and store it with the same care as any consumer report. `result` repeats the outcome from **Complete**.

**Headers**

- `Content-Type: application/json`
- `User-Agent: P51-Partner-Gateway/1.0`
- `X-P51-Event: consumer.transaction.report_generated`
- `X-P51-Timestamp: 2026-09-15T18:22:31.000000Z`
- `X-P51-Signature: 3f1c9a0e7b…`

**Body**

```json
{
  "event": "consumer.transaction.report_generated",
  "status": "complete",
  "external_id": "HR-2026-001234",
  "consumer_transaction_id": "98765",
  "consumer": {
    "id": "54321",
    "first_name": "John",
    "last_name": "Doe"
  },
  "portal_url": "https://app.psbi.com/client/autopilot/consumers/54321",
  "occurred_at": "2026-09-15T18:22:31Z",
  "result": "needs_review",
  "report_url": "https://<psbi-report-storage>/reports/98765.pdf"
}
```
