---
title: How to Integrate with the BambooHR API (2026 Engineering Guide)
slug: how-to-integrate-with-the-bamboohr-api-2026-engineering-guide
date: 2026-03-30
author: Sidharth Verma
categories: [Guides, By Example]
excerpt: "BambooHR API guide: auth, pagination, rate limits, and hidden costs. Plus a Quick Start for pulling users from every connected SaaS app through a single unified API."
tldr: BambooHR's API hides rate limits and a 400-field cap. A unified API lets you pull and aggregate users from BambooHR and every other connected SaaS app through one endpoint.
canonical: https://truto.one/blog/how-to-integrate-with-the-bamboohr-api-2026-engineering-guide/
---

# How to Integrate with the BambooHR API (2026 Engineering Guide)


Your engineer just said integrating with BambooHR will take a sprint, falling into the classic ["just a few API calls" trap](https://truto.one/blog/building-native-hris-integrations-without-draining-engineering-in-2026/). They're looking at the REST endpoints, the clean JSON responses, and the straightforward Basic Auth header. They're right about the happy path — that part genuinely is simple. What they haven't priced in is the undocumented throttling, the 400-field ceiling you'll hit the moment you try to pull a full employee record, and the OAuth migration BambooHR started enforcing in 2025.

This guide breaks down the architectural realities of building a native BambooHR integration. We cover authentication mechanics, pagination constraints, rate limit handling, webhook architecture, and the hidden maintenance costs that drain engineering resources quarter after quarter.

If your real goal is broader - pulling a list of users from every SaaS app your customers have connected, not just BambooHR - skip ahead to the [Quick Start](#quick-start-pull-users-across-all-connected-saas-apps). It covers how to aggregate users across BambooHR, Okta, Google Workspace, and dozens of other providers through a single API, with no per-provider code required.

## Understanding the BambooHR API Architecture

The BambooHR API is a RESTful service where all requests route over HTTPS to a company-specific base URL:

```
https://api.bamboohr.com/api/gateway.php/{yourSubdomain}/v1/
```

Unlike centralized APIs where all traffic hits a single `api.vendor.com` host, BambooHR requires the customer's exact company subdomain to construct the base URL. This matters — your application must capture this information during the onboarding flow.

<cite index="2-14">Each employee has an immutable employee ID that is unique within a single company that you can use to reference the employee.</cite> The API covers employees, time-off, benefits, time tracking, applicant tracking, webhooks, and custom reports. You can request specific fields by passing a comma-separated list in the `fields` query parameter:

```bash
curl -u "{API_KEY}:x" \
  "https://api.bamboohr.com/api/gateway.php/acme/v1/employees/123?fields=firstName,lastName,department,jobTitle"
```

<cite index="2-6">Currently, the only version is "V1."</cite> However, <cite index="21-22">BambooHR has added three new Datasets endpoints under /api/v1_2/ that provide the same dataset and field discovery capabilities as their /api/v1/ counterparts, with improved API consistency.</cite> For new integrations, it's worth evaluating the v1.2 endpoints alongside the standard v1 surface.

**Core entities in the data model:**
- **Employees:** The central node. Returns demographic data, contact information, and organizational placement.
- **Employment Status:** Historical and current records of an employee's lifecycle (hired, terminated, promoted).
- **Time Off:** Balances, policies, and individual time-off requests.
- **Custom Tables:** BambooHR allows administrators to define custom fields and tables, meaning the schema you expect will rarely match the schema your enterprise customers actually use.

<cite index="7-1,7-2">BambooHR provides official SDKs to help you integrate with their API quickly and reliably. Their SDKs handle authentication, error handling, retries, and provide fully typed models.</cite> As of early 2026, official SDKs exist for PHP and Python, both supporting OAuth 2.0.

### The Subdomain Routing Problem

Because the base URL requires a subdomain, your application must capture this information during onboarding. If a user inputs the wrong subdomain, or if their company rebrands and changes their BambooHR URL, your integration instantly breaks with a 404. Your database schema for storing connected accounts must treat the subdomain as a mutable configuration variable, not a static identifier.

## Authentication: API Keys vs. OAuth 2.0

BambooHR supports two authentication methods, and the distinction matters more than it used to.

### API Key via Basic Auth

The simplest path. <cite index="34-21,34-22">BambooHR uses Basic Authentication. For the username, you use your API key and for the password, you can use anything (commonly 'x').</cite> <cite index="34-38,34-39">The API key inherits the permission set of the user who created it. If a user doesn't have access to certain fields, the API key they generate won't have access to those fields either.</cite>

```typescript
// Formatting the BambooHR auth header in Node.js
const apiKey = process.env.BAMBOOHR_API_KEY;
const encodedCredentials = Buffer.from(`${apiKey}:x`).toString('base64');

const headers = {
  'Authorization': `Basic ${encodedCredentials}`,
  'Accept': 'application/json'
};

const response = await fetch(
  `https://api.bamboohr.com/api/gateway.php/${subdomain}/v1/employees/directory`,
  { method: 'GET', headers }
);
```

Two gotchas that bite people in production:

1. **Key lockout.** <cite index="1-3,1-4,1-5">If an unknown API key is used repeatedly, the API will disable access for a period of time. Users will still be able to log in to the BambooHR website during this time. When the API is disabled, it will send back an HTTP 403 Forbidden response to any requests it receives.</cite> If you're debugging with the wrong key in a tight loop, you'll lock yourself out.

2. **Double round-trips.** <cite index="13-1,13-2,13-3">When an API request is made without credentials, BambooHR returns a 401 response with a WWW-Authenticate: Basic realm="..." header. Some HTTP clients use this as a signal to retry the request with credentials — a pattern known as HTTP authentication negotiation. This doubles the number of HTTP round trips for every API call.</cite> <cite index="13-4">BambooHR strongly recommends configuring your integration to include credentials on every API request from the start rather than relying on the challenge-response cycle.</cite>

> [!WARNING]
> Always send the `Authorization` header preemptively. Relying on the 401 challenge-response pattern wastes your rate limit budget on failed requests and doubles latency.

### Storing Credentials Securely

API keys grant broad access to sensitive HR data — compensation, social security numbers, home addresses. Storing these in plain text in your database is a massive liability. Your infrastructure must encrypt keys at rest using AES-256-GCM or equivalent, decrypting them only in memory immediately before the HTTP request is dispatched.

### OAuth 2.0 (Authorization Code Flow)

For multi-tenant integrations or marketplace apps, OAuth 2.0 is now the required path. <cite index="32-1,32-2">BambooHR deprecated its OpenID Connect Login API as of April 14, 2025. Applications created after that date must use OAuth 2.0 tokens for authentication instead of User API Keys.</cite>

The flow follows a standard authorization code grant:

1. Redirect the user to `https://{companyDomain}.bamboohr.com/authorize.php` with your `client_id`, `redirect_uri`, requested scopes, and `response_type=code`
2. BambooHR redirects back with a temporary `code`
3. Exchange that code at the token endpoint for an `access_token` and `refresh_token`

```mermaid
sequenceDiagram
    participant App as Your App
    participant Browser as User Browser
    participant BHR as BambooHR
    App->>Browser: Redirect to authorize.php
    Browser->>BHR: User logs in, grants consent
    BHR->>Browser: Redirect to callback with ?code=
    Browser->>App: Callback with auth code
    App->>BHR: POST /token.php (code + client_secret)
    BHR->>App: access_token + refresh_token
    App->>BHR: API requests with Bearer token
```

The access token expires in 3600 seconds. You'll only receive a `refresh_token` if you include the `offline_access` scope in your initial authorization request — miss that scope and you're stuck re-authorizing users every hour.

<cite index="33-14,33-15,33-16">The Redirect URI is the URL you registered when creating your app in the BambooHR Developer Portal. During the OAuth flow, BambooHR redirects users to this address along with a temporary authorization code. Make sure the Redirect URI you use in your requests exactly matches the one you registered — even small differences (such as an extra slash or capitalization change) can cause authentication errors.</cite>

> [!TIP]
> **Which should you pick?** API keys are fine for internal tooling where a single BambooHR admin owns the integration. For a B2B SaaS product connecting to your customers' BambooHR accounts, OAuth 2.0 is required. BambooHR is actively deprecating legacy auth paths — build on OAuth from day one.

## Handling BambooHR API Pagination and Rate Limits

This is where BambooHR's "simple API" reputation starts to crack.

### The New Cursor-Based Pagination Endpoint

<cite index="21-1,21-2">BambooHR added a new public API endpoint, GET /api/v1/employees, which provides a more flexible and efficient way to retrieve employee data. This endpoint supports filtering, sorting, and pagination, allowing developers to request only the employees and fields they need.</cite>

Before this endpoint (added in late 2025), you had two options for bulk employee retrieval: the `/employees/directory` endpoint (which dumps everyone at once with limited fields) or custom reports (which require you to pre-define the fields you want). Neither supported proper cursor-based pagination.

<cite index="21-11,21-12">The existing directory endpoint and custom reports remain available and unchanged. No action is required for current integrations; however, developers may review the new endpoint to determine whether its capabilities better align with their use cases.</cite>

### The 400-Field Limit

<cite index="12-13,12-14">BambooHR set a limit of up to 400 fields that can be requested for a single request to the Get Employee endpoint. This limit is being applied to help ensure the stability and performance of their systems for everyone.</cite>

<cite index="12-15,12-16,12-17">BambooHR is also researching a reasonable limit to the number of fields that can be requested in a single custom report. 90% of uses of the Request Custom Report endpoint request 30 or less fields. However there are some requests that attempt to get 1000 or more fields in a single request.</cite>

If your integration needs more than 400 fields per employee (common in companies with many custom fields), you'll need to batch your field requests across multiple API calls and merge the results client-side. For a complex analytics product requiring 600 fields, that means executing multiple paginated passes and stitching the JSON payloads in memory before writing to your database.

### Rate Limiting: The Undocumented Problem

Here's where things get frustrating. <cite index="14-1,14-2,14-3">BambooHR does have rate limits, but specific details are not easily found in the public documentation. API requests can be throttled if BambooHR deems them to be too frequent. Implementations should always be prepared for a 503 Service Unavailable response.</cite>

<cite index="14-8">When rate limiting occurs, a "Retry-After" header may be available in the response, indicating when it's appropriate to retry the request.</cite> That "may be" is doing a lot of heavy lifting — you can't rely on it being there.

When you hit their limits, the API throws specific HTTP status codes:
- **429 Limit Exceeded:** You're making too many requests per second.
- **503 Service Unavailable:** The BambooHR gateway is overwhelmed and dropping connections.

If your integration relies on a naive `while` loop to fetch data, a 429 error will crash your sync job. Here's a production-grade retry pattern:

```python
import time
import random
import requests
from requests.auth import HTTPBasicAuth

def bamboo_request(url, api_key, max_retries=5):
    for attempt in range(max_retries):
        response = requests.get(
            url,
            auth=HTTPBasicAuth(api_key, "x"),
            headers={"Accept": "application/json"}
        )
        
        if response.status_code == 200:
            return response.json()
        
        if response.status_code in (429, 503):
            retry_after = response.headers.get("Retry-After")
            if retry_after:
                wait = int(retry_after)
            else:
                # Exponential backoff with jitter
                wait = min(2 ** attempt + random.uniform(0, 1), 60)
            
            time.sleep(wait)
            continue
        
        response.raise_for_status()
    
    raise Exception(f"Max retries exceeded for {url}")
```

Here's what a typical sync flow looks like when backoff kicks in:

```mermaid
sequenceDiagram
    participant SaaS as Your Application
    participant Worker as Background Job
    participant Bamboo as BambooHR API
    
    SaaS->>Worker: Trigger Directory Sync
    Worker->>Bamboo: GET /v1/employees?cursor=null
    Bamboo-->>Worker: HTTP 200 (Page 1)
    Worker->>Bamboo: GET /v1/employees?cursor=xyz123
    Bamboo-->>Worker: HTTP 429 Limit Exceeded
    Note over Worker: Sleep for 2 seconds<br>Retry request
    Worker->>Bamboo: GET /v1/employees?cursor=xyz123
    Bamboo-->>Worker: HTTP 503 Service Unavailable
    Note over Worker: Sleep for 4 seconds<br>Retry request
    Worker->>Bamboo: GET /v1/employees?cursor=xyz123
    Bamboo-->>Worker: HTTP 200 (Page 2)
```

> [!NOTE]
> BambooHR's rate limits are intentionally undocumented and subject to change at their discretion. <cite index="19-3">BambooHR may, in its sole discretion, limit, modify, suspend, or discontinue access to any Developer Tools or specific API endpoints at any time, including by imposing or adjusting rate limits.</cite> Build your integration assuming limits will tighten over time.

For a deeper look at handling rate limits across multiple HR platforms, see our [guide to API rate limit best practices](https://truto.one/blog/best-practices-for-handling-api-rate-limits-and-retries-across-multiple-third-party-apis/).

## Webhooks vs. Polling for Real-Time Sync

To keep your application updated when an employee is hired or terminated, you have two choices: poll the API on a cron schedule, or listen for webhooks.

BambooHR has made meaningful improvements here. <cite index="3-28,3-29,3-30">They transitioned from a cron-based system to a real-time event-driven architecture. With this transition, they removed webhook scheduling and rate limiting features from the user interface as they are no longer needed for real-time delivery. If your webhook configuration used scheduling or rate limiting features, your webhooks automatically transitioned to real-time delivery with no action required.</cite>

<cite index="21-14,21-15,21-16">They also expanded webhook functionality to include support for custom fields, allowing customers and partners to monitor and receive real-time notifications when their own custom data changes.</cite>

That said, relying purely on webhooks introduces operational overhead:

1. **Delivery guarantees.** If your receiving endpoint goes down for maintenance, payloads may be dropped. You need a highly available queue immediately behind your webhook receiver to ensure no data is lost.
2. **Idempotency.** Duplicate events happen. Your processing logic needs to handle receiving the same event multiple times without corrupting state.
3. **Manual configuration friction.** BambooHR webhooks often require the customer's HR admin to log into their dashboard and manually configure the endpoint URL and select trigger fields. This creates friction during onboarding.

Because of these realities, most engineering teams implement a hybrid approach: webhooks for real-time updates, plus a nightly polling job to catch missed events and ensure absolute data consistency.

## The Hidden Costs of Building a Native BambooHR Integration

Let's do the math that sprint planning never does.

**Week 1-2:** Your engineer builds the happy path. Basic auth, pull employees, map fields. Demo looks great. Ship it.

**Month 2:** A customer reports missing custom fields. Turns out the API key they generated doesn't have admin permissions. Debugging takes a day because the API just silently omits fields the key can't access — no error, no warning.

**Month 4:** BambooHR pushes a change to their webhook system. Some fields your integration relied on are removed. <cite index="3-31,3-32,3-33">BambooHR removed some webhook fields — some no longer exist in their database, while others had extremely low usage. They reached out directly to impacted customers, but your customer didn't forward the notice to your engineering team.</cite>

**Month 8:** Your second enterprise customer uses BambooHR with 2,000 employees and 200 custom fields. Your integration hits the 400-field limit and starts silently dropping data. The pagination approach that worked for a 50-person company now triggers rate limits.

This pattern repeats across every HRIS vendor, not just BambooHR—and we see the exact same maintenance burden when teams build accounting integrations for [QuickBooks](https://truto.one/blog/how-to-integrate-with-the-quickbooks-online-api-2026-guide/), [FreshBooks](https://truto.one/blog/how-to-integrate-with-the-freshbooks-api-2026-engineering-guide/), or [Xero](https://truto.one/blog/how-do-i-integrate-with-the-xero-api-a-guide-for-b2b-saas/). <cite index="41-1">Organizations need $50,000 to $150,000 yearly to cover staff and partnership fees</cite> for ongoing API integration maintenance, according to research by Netguru. That's not the cost of building — that's the cost of *keeping it running*.

| Cost Category | Estimated Annual Cost |
|---|---|
| Initial build (amortized) | $15,000 – $30,000 |
| Ongoing maintenance & monitoring | $20,000 – $50,000 |
| Edge case debugging | $10,000 – $25,000 |
| Auth changes & API deprecation handling | $5,000 – $15,000 |
| Customer support escalations | $5,000 – $15,000 |
| **Total (single vendor)** | **$55,000 – $135,000** |

And that's for **one** HRIS provider. Most B2B SaaS products need to support BambooHR, Workday, ADP, Gusto, Rippling, and a half-dozen others. Multiply accordingly.

Every hour spent babysitting third-party HR APIs is an hour not spent on the core product your customers actually pay for. For a detailed breakdown, read our post on [building native HRIS integrations without draining engineering](https://truto.one/blog/building-native-hris-integrations-without-draining-engineering-in-2026/).

## How Truto Handles BambooHR (and 50+ Other HRIS Providers)

Let's be honest about trade-offs. A unified API adds an abstraction layer between you and the vendor API. That means less raw control, and there will be edge cases where you need data outside the unified schema. Any vendor who tells you their unified API covers 100% of every provider's surface area is lying to you.

That said, here's what Truto's architecture actually does for BambooHR integrations:

**Zero integration-specific code.** You don't write code specific to BambooHR — you write code against the Truto Unified Schema. When you request an employee directory, you hit the Truto `/unified/hris/employees` endpoint. The platform automatically translates that request into BambooHR's native format, handles the 400-field batching, manages cursor-based pagination, and returns a clean, standardized JSON array.

If the customer uses Workday instead of BambooHR, your code doesn't change. Truto routes the request to the correct provider using the `integrated_account_id` parameter.

```bash
curl -H "Authorization: Bearer {TRUTO_TOKEN}" \
  "https://api.truto.one/unified/hris/employees?integrated_account_id={ACCOUNT_ID}"
```

**The unified data model.** Instead of BambooHR's field-name-based schema, you work with standardized entities:

```mermaid
erDiagram
    COMPANY ||--o{ LOCATION : has
    COMPANY ||--o{ GROUP : has
    COMPANY ||--o{ JOB_ROLE : defines
    EMPLOYEE }o--|| COMPANY : belongs_to
    EMPLOYEE ||--o{ EMPLOYMENT : has
    EMPLOYEE }o--o{ GROUP : member_of
    EMPLOYEE }o--o| JOB_ROLE : assigned
    EMPLOYEE ||--o{ TIMEOFF_REQUEST : submits
    EMPLOYEE ||--o{ EMPLOYEE_COMPENSATION : receives
```

This schema holds whether you're pulling from BambooHR, Workday, or Personio.

**Automatic auth lifecycle management.** Truto manages both API key storage (encrypted at rest) and OAuth 2.0 token lifecycles. For OAuth-connected accounts, Truto refreshes tokens shortly before they expire. If a refresh fails, the account is flagged as needing re-authorization and your application is notified via webhook — no silent failures.

**Pagination and rate limit handling.** Truto's pagination system adapts to each provider's approach. For BambooHR, that means using cursor-based pagination on the new employees endpoint and handling the 400-field batching automatically. Rate limit responses trigger exponential backoff within the platform, so your application never sees a 429 or 503.

**What Truto doesn't solve.** If you need BambooHR-specific features outside the unified schema — like time tracking break policies or applicant tracking modules — you can use Truto's Proxy API to make direct calls to BambooHR's native endpoints. The Proxy API still gives you managed auth and rate limiting, but you're back to provider-specific response formats.

For companies that need to handle custom field mappings that differ between customers, Truto uses a three-level override hierarchy: platform defaults, environment-level overrides for your product, and per-account overrides for individual customers with unusual configurations.

> Need to ship BambooHR alongside Workday, ADP, and Gusto without staffing an integrations team? Truto's Unified HRIS API normalizes employee data across 50+ providers through a single schema.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)

## Quick Start: Pull Users Across All Connected SaaS Apps

If you landed here because you need to pull a list of users from every SaaS app your customers use - BambooHR, Google Workspace, Okta, Microsoft Entra ID - without building each integration from scratch, this section is your starting point.

The pattern is the same whether you're pulling from one provider or twenty: authenticate with Truto, specify which connected account to query, and get back a normalized response.

### Step 1: Authenticate with Truto

Every request requires a Bearer token (your Truto API key) and an `integrated_account_id` that identifies a specific customer's connected account - for example, "Acme Corp's BambooHR instance."

```bash
# All Truto requests use the same auth pattern
curl -H "Authorization: Bearer {TRUTO_TOKEN}" \
     -H "Accept: application/json" \
     "https://api.truto.one/unified/hris/employees?integrated_account_id={ACCOUNT_ID}"
```

No BambooHR API keys, no subdomain routing, no Basic Auth encoding. Truto handles the provider-specific authentication for the connected account.

### Step 2: Read the Response

<cite index="15-4">Truto always transforms any underlying pagination format to a cursor-based pagination format.</cite> <cite index="17-38,17-39">Truto follows a single response format across all integrations, with unified cursor-based pagination and all data present in the `result` attribute.</cite>

```json
{
  "result": [
    {
      "id": "truto_emp_8a3f",
      "remote_id": "100",
      "first_name": "Jane",
      "last_name": "Doe",
      "email": "jane.doe@acme.com",
      "department": "Engineering",
      "job_title": "Staff Engineer",
      "employment_status": "active",
      "start_date": "2023-03-15",
      "termination_date": null,
      "manager": {
        "id": "truto_emp_00042",
        "remote_id": "42"
      },
      "created_at": "2023-03-15T08:00:00Z",
      "modified_at": "2026-01-10T14:22:00Z",
      "remote_data": {}
    }
  ],
  "next_cursor": "eyJwYWdlIjoyLCJsaW1pdCI6MTAwfQ=="
}
```

This response looks identical whether the source is BambooHR, Workday, Gusto, or any other supported HRIS. Swap the `integrated_account_id` and the schema stays the same.

### Step 3: Pull from Identity Providers Too

For non-HRIS SaaS apps - directory services like Okta, Google Workspace, or Microsoft Entra ID - use the User Directory API:

```bash
curl -H "Authorization: Bearer {TRUTO_TOKEN}" \
     -H "Accept: application/json" \
     "https://api.truto.one/unified/user-directory/users?integrated_account_id={OKTA_ACCOUNT_ID}"
```

Same auth pattern, same pagination model, different unified schema optimized for identity and access data. Between the HRIS and User Directory APIs, you can cover the vast majority of SaaS apps where user records live.

## Aggregating Users Across Multiple Providers

Here's the practical answer to "how do I pull users from all the SaaS apps my customers use?" The pattern is: iterate over every connected account for a customer and hit the same unified endpoint for each one.

```python
import requests

TRUTO_TOKEN = "your_truto_api_token"
BASE = "https://api.truto.one"
HEADERS = {
    "Authorization": f"Bearer {TRUTO_TOKEN}",
    "Accept": "application/json"
}

# Each entry represents one of your customer's connected accounts.
# You store these when the customer links their SaaS apps via Truto's SDK.
connected_accounts = [
    {"id": "ia_bamboohr_acme",  "provider": "bamboohr",        "category": "hris"},
    {"id": "ia_okta_acme",      "provider": "okta",             "category": "user-directory"},
    {"id": "ia_gworkspace_acme","provider": "google-workspace", "category": "user-directory"},
]

all_users = []

for account in connected_accounts:
    # Pick the right unified endpoint based on category
    if account["category"] == "hris":
        endpoint = f"{BASE}/unified/hris/employees"
    else:
        endpoint = f"{BASE}/unified/user-directory/users"

    cursor = None
    while True:
        params = {"integrated_account_id": account["id"]}
        if cursor:
            params["cursor"] = cursor

        resp = requests.get(endpoint, headers=HEADERS, params=params)
        resp.raise_for_status()
        data = resp.json()

        for user in data["result"]:
            user["_source_provider"] = account["provider"]
            user["_source_account_id"] = account["id"]
            all_users.append(user)

        cursor = data.get("next_cursor")
        if not cursor:
            break

print(f"Pulled {len(all_users)} users across {len(connected_accounts)} providers")
```

The unified schema means you don't write provider-specific parsing logic. Every response maps to the same field names regardless of whether it came from BambooHR, Okta, or Google Workspace.

```mermaid
flowchart LR
    A[Your App] -->|Same Bearer token| B[Truto API]
    B -->|integrated_account_id=bamboohr| C[BambooHR]
    B -->|integrated_account_id=workday| D[Workday]
    B -->|integrated_account_id=okta| E[Okta]
    B -->|integrated_account_id=google| F[Google Workspace]
    C --> G[Unified JSON]
    D --> G
    E --> G
    F --> G
    G --> H[Your Database]
```

This works whether a customer has connected two providers or twenty. The `integrated_account_id` parameter is the only thing that changes between requests.

## Pagination and Large Result Sets

When a customer has thousands of employees, results come back in pages. Every response includes a `next_cursor` field - pass it back as a query parameter to get the next page.

```bash
# First page
curl -H "Authorization: Bearer {TRUTO_TOKEN}" \
     "https://api.truto.one/unified/hris/employees?integrated_account_id={ACCOUNT_ID}&limit=100"

# Response includes: "next_cursor": "eyJwYWdlIjoyLCJsaW1pdCI6MTAwfQ=="

# Next page
curl -H "Authorization: Bearer {TRUTO_TOKEN}" \
     "https://api.truto.one/unified/hris/employees?integrated_account_id={ACCOUNT_ID}&limit=100&cursor=eyJwYWdlIjoyLCJsaW1pdCI6MTAwfQ=="
```

When `next_cursor` is `null` or absent, you've reached the end. This model is consistent across every provider. BambooHR uses cursor-based pagination natively on its newer endpoint, but other providers may use offset-based or page-number-based pagination internally - Truto normalizes all of them into the same cursor model on your side.

For the aggregation use case, paginate each provider fully before moving to the next. This keeps memory predictable and makes it straightforward to resume if a sync job is interrupted partway through.

> [!TIP]
> For very large directories (10,000+ employees), fetching every record in real-time on every request can be slow and will consume the underlying provider's rate limit budget. Consider syncing to your own database on a schedule and serving reads from there. Truto's real-time pass-through is ideal for on-demand reads and incremental updates, but bulk initial loads benefit from a background sync pattern.

## Error Handling and Retry Patterns

The Truto API uses standard HTTP status codes. Here's what you'll encounter and how to handle each:

| Status Code | Meaning | Action |
|---|---|---|
| 200 | Success | Process the response |
| 400 | Bad request (invalid params) | Check query parameters and fix the request |
| 401 | Invalid or expired Truto token | Refresh your Truto API token |
| 404 | Account or resource not found | Verify the `integrated_account_id` is correct and active |
| 422 | Unprocessable entity | The provider rejected the request - check field values |
| 429 | Rate limit exceeded | Back off and retry (see below) |
| 502/503 | Upstream provider unavailable | Retry with exponential backoff |

A retry wrapper that handles transient failures:

```python
import time
import random
import requests

def truto_request(url, headers, params, max_retries=5):
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers, params=params)

        if resp.status_code == 200:
            return resp.json()

        if resp.status_code in (429, 502, 503):
            retry_after = resp.headers.get("Retry-After")
            wait = int(retry_after) if retry_after else min(2 ** attempt + random.uniform(0, 1), 60)
            time.sleep(wait)
            continue

        if resp.status_code == 401:
            raise Exception("Truto API token is invalid or expired")

        resp.raise_for_status()

    raise Exception(f"Max retries exceeded for {url}")
```

<cite index="5-2,5-8">Truto relies on real-time data and does not cache it, which means it counts on you, the customer, to manage the rate limiting.</cite> <cite index="5-13">Since Truto calls data in real time, you should keep your request rate within the underlying API's limit.</cite> Truto handles provider-level backoff (retrying when BambooHR returns a 429), but your application is responsible for not hammering the unified API faster than the underlying provider can handle.

### Troubleshooting Common Issues

| Symptom | Likely Cause | Fix |
|---|---|---|
| Empty `result` array | The connected account has no records, or the connection is stale | Verify the account is active in the Truto dashboard. Re-authorize if needed. |
| `401 Unauthorized` | Your Truto API token is expired or invalid | Generate a new API token from the Truto dashboard |
| `404 Not Found` | The `integrated_account_id` doesn't exist or was deleted | Confirm the account ID is correct and the connection is still live |
| Fields returning `null` | The source provider doesn't supply that field, or the connected user lacks permission | Check the `remote_data` field to see what the provider actually returned. Use the Proxy API for direct access if needed. |
| Slow responses on large directories | The underlying provider is paginating through thousands of records in real-time | Use smaller page sizes via the `limit` parameter. For bulk loads, sync to your own database on a schedule. |

## Sample Response and Field Reference

Here's a complete response from the unified HRIS employees endpoint. This format is identical whether the source is BambooHR, Workday, Personio, or any other connected provider:

```json
{
  "result": [
    {
      "id": "truto_emp_8a3f",
      "remote_id": "100",
      "first_name": "Jane",
      "last_name": "Doe",
      "display_name": "Jane Doe",
      "email": "jane.doe@acme.com",
      "personal_email": "jane@personal.com",
      "department": "Engineering",
      "job_title": "Staff Engineer",
      "employment_status": "active",
      "employment_type": "full_time",
      "start_date": "2023-03-15",
      "termination_date": null,
      "work_location": "San Francisco",
      "manager": {
        "id": "truto_emp_00042",
        "remote_id": "42"
      },
      "groups": [
        {
          "id": "grp_eng",
          "name": "Engineering",
          "type": "department"
        }
      ],
      "created_at": "2023-03-15T08:00:00Z",
      "modified_at": "2026-01-10T14:22:00Z",
      "remote_data": {}
    }
  ],
  "next_cursor": "eyJwYWdlIjoyLCJsaW1pdCI6MTAwfQ=="
}
```

Key fields:

- **`id`** - Truto's stable identifier for the record across syncs.
- **`remote_id`** - The employee's ID in the source provider (e.g., BambooHR's numeric employee ID). Use this when cross-referencing with the native system.
- **`employment_status`** - <cite index="16-4,16-5">Represents the employment status. If no clear mapping is available, then the raw value is returned.</cite> Normalized values include `active`, `inactive`, `terminated`, and `on_leave` where possible.
- **`manager`** - <cite index="16-1,16-2">Represents the manager of the employee. Is also an employee.</cite> Returned as a nested object with its own `id` and `remote_id`.
- **`remote_data`** - <cite index="16-12">Raw data returned from the remote API call.</cite> When you need a BambooHR-specific field that isn't in the unified schema, check here before falling back to the Proxy API.
- **`groups`** - <cite index="12-9,12-10">Type of the group. Some underlying providers use this to differentiate between built-in and user-created groups.</cite>

## Data Residency and Zero-Cache Behavior

Security teams evaluating a unified API need clear answers on where employee PII lives. Here's how Truto handles it:

**Pass-through architecture.** <cite index="4-31">Instead of caching third-party data on its servers, Truto operates as a real-time pass-through proxy - every API call flows directly to the source system, gets transformed on the fly, and returns to your application without Truto storing the payload.</cite> Employee names, emails, compensation data, and other PII are processed in memory during the request-response cycle and never written to persistent storage.

**What this means for compliance.** <cite index="2-26,2-27,2-28,2-29">By using an integration tool with a pass-through architecture, you bypass this trap entirely. Because the middleware does not store the data, it's classified as a conduit rather than a data custodian.</cite> This simplifies your data processing agreements and vendor risk assessments.

**Credentials at rest.** OAuth tokens and API keys for connected accounts are encrypted at rest. Tokens are decrypted only in memory at the moment the API request is dispatched.

**Compliance certifications.** <cite index="4-23,4-24">Truto is SOC 2 Type II and ISO 27001 compliant, GDPR and HIPAA certified.</cite> <cite index="4-25">The pass-through architecture makes compliance reviews with enterprise security teams dramatically simpler.</cite>

**Deployment options.** <cite index="7-8">You can choose between Truto Cloud and Truto on-prem.</cite> For organizations that need the API layer to run entirely within their own infrastructure, the on-prem option keeps all data transit within your network boundary.

**The honest trade-off.** <cite index="1-4,1-5,1-6">Pass-through architectures are slower than cached ones. Walking through 50 pages of a third-party API on every request adds real latency.</cite> <cite index="1-7">If you need sub-100ms response times on integration data, you'll need to cache data in your own infrastructure where you control retention, encryption, and residency.</cite> The pass-through model gives you the cleanest compliance posture, but for high-frequency read patterns, plan to sync data into your own database on a schedule and serve reads from there.

## What Should You Actually Do?

Here's the decision framework:

**Build it yourself if:**
- BambooHR is the only HRIS you'll ever support (and you're certain about that)
- You need deep access to BambooHR-specific features like time tracking break policies or benefits administration
- Your team has spare capacity and you want full control over the data pipeline

**Use a unified API if:**
- Your sales team is already asking for Workday, ADP, and Gusto support alongside BambooHR
- You'd rather spend engineering cycles on your core product
- You need to move fast — integrating through a unified API takes days, not quarters

BambooHR's API is one of the friendlier HRIS APIs out there. The docs are decent (by HR vendor standards), the data model is flat, and the REST conventions are mostly standard. But "friendlier" doesn't mean "free to maintain." The auth migration alone — from API keys to OAuth 2.0, with the OpenID Connect deprecation thrown in — is a reminder that even the simplest vendor APIs change underneath you.

You have to handle Basic Auth with API keys securely, navigate the strict 400-field limit, build cursor-based pagination loops, and implement exponential backoff for undocumented rate limits. While building this in-house is entirely possible, the ongoing maintenance burden makes it a poor use of highly paid engineering talent the moment you need more than one provider. And in HRIS, you always need more than one provider.

If you're weighing the [build vs. buy decision for integrations](https://truto.one/blog/build-vs-buy-the-true-cost-of-building-saas-integrations-in-house/), the math usually tips toward buy somewhere around provider number two. To evaluate how different platforms handle this architecture, review our comparison of [alternatives for HRIS integrations](https://truto.one/blog/what-are-the-best-alternatives-to-kombo-for-hris-integrations-in-2026/).
