---
title: "BambooHR to Greenhouse Migration: The CTO's Technical Guide"
slug: bamboohr-to-greenhouse-migration-the-ctos-technical-guide
date: 2026-04-21
author: Raaj
categories: [Migration Guide, Greenhouse, BambooHR]
excerpt: "A technical guide for CTOs migrating ATS data from BambooHR to Greenhouse. Covers API constraints, data model mapping, attachment handling, and rate limits."
tldr: "BambooHR's flat applicant model must be split into Greenhouse's relational Candidate + Application structure. The real bottlenecks: attachments (base64 encoding), Harvest v3 rate limits, and dependency-ordered loading."
canonical: https://clonepartner.com/blog/bamboohr-to-greenhouse-migration-the-ctos-technical-guide/
---

# BambooHR to Greenhouse Migration: The CTO's Technical Guide


Migrating from BambooHR's built-in ATS to Greenhouse is a data-model translation problem, not a simple export-import. BambooHR treats applicants as **flat records attached to job openings** — one applicant profile per application, with statuses, ratings, resume file IDs, and comments bundled into a single entity. Greenhouse separates this into distinct **Candidate** (the person) and **Application** (the candidacy for a specific job) records, where one Candidate can have multiple Applications across different Jobs.

A naive CSV export flattens this relationship, drops resumes and attachments (BambooHR stores them as file IDs, not inline content), and leaves you with no scorecard data on the Greenhouse side. The real bottleneck isn't moving structured fields — it's extracting attachments, translating the flat-to-relational schema, and navigating Greenhouse's Harvest API rate limits.

This guide covers every viable migration method and its trade-offs, the API constraints on both sides, object-mapping decisions, and the edge cases that break most DIY attempts.

For related reading, see our coverage on [common ATS migration gotchas](https://clonepartner.com/blog/blog/ats-migration-gotchas/), [GDPR/CCPA compliance during candidate data transfers](https://clonepartner.com/blog/blog/ats-migration-gdpr-ccpa-compliance/), and the [Lever to Greenhouse migration guide](https://clonepartner.com/blog/blog/lever-to-greenhouse-migration-the-ctos-technical-guide/) for a deeper look at Greenhouse's target data model.

> [!WARNING]
> Greenhouse Harvest API v1 and v2 will be deprecated and unavailable after **August 31, 2026**. Build new import pipelines against Harvest v3 (OAuth 2.0). Do not build on v1/v2 only to rewrite months later. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360029266032-Harvest-API-overview))

## Why Companies Migrate from BambooHR's ATS to Greenhouse

The drivers typically fall into three categories:

- **Outgrowing the built-in ATS.** BambooHR's ATS is designed to feed applicants into its HRIS — it optimizes for the HR manager's workflow, not the recruiter's. As hiring volume scales, teams hit limitations in pipeline customization, sourcing tooling, and reporting depth. Greenhouse was built for structured evaluation pipelines with scorecards, approval workflows, and configurable interview stages.
- **Structured hiring at scale.** Greenhouse enforces consistent, auditable interview processes across departments and geographies. Its scorecard system — where interviewers rate candidates against predetermined attributes — is a core architectural feature, not a bolt-on.
- **Integration ecosystem.** Greenhouse has 500+ integrations spanning job boards, assessment platforms, background check providers, and scheduling tools. BambooHR's ATS ecosystem is narrower by design — it prioritizes HRIS interoperability over recruiting tooling. Most companies keep BambooHR as HRIS, move recruiting into Greenhouse, and use the native Greenhouse-to-BambooHR integration for the hired-candidate handoff. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/201177624-I-use-BambooHR-What-does-the-integration-look-like-How-do-I-enable-it-?mobile_site=true))

### Core Data Model Differences

| Concept | BambooHR ATS | Greenhouse |
|---|---|---|
| **Person** | Applicant (flat record) | Candidate (relational) |
| **Candidacy** | Application (1:1 with applicant per job) | Application (many per Candidate, linked to Job) |
| **Evaluation** | Star rating + comments | Scorecards with structured attribute ratings |
| **Pipeline** | Fixed status workflow | Configurable stages per job with interview plans |
| **Attachments** | File IDs (separate fetch) | Base64-encoded uploads (v1/v2) or separate resource requiring `application_id` (v3) |
| **Custom fields** | Limited | Candidate-level, application-level, and job-level |

## Migration Approaches: CSV, API, ETL, and Middleware

### 1. Native CSV Export/Import

**How it works:** Export candidate data from BambooHR as CSV via the ATS reporting UI or by requesting a full account data export. Clean and reformat the CSV to match Greenhouse's import template. Upload via Greenhouse's bulk import, which also supports a resume ZIP file — Greenhouse matches resumes to candidates by parsed email. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360053674012-Bulk-import-candidates-from-spreadsheet?utm_source=openai))

**When to use:** Small datasets, no complex attachment requirements, no need to preserve pipeline history.

**Pros:**
- Zero engineering effort
- Familiar tooling (Excel, Google Sheets)
- Greenhouse tags imported candidates for traceability

**Cons:**
- Loses resume/cover letter files unless separately uploaded as a ZIP (and matching depends on parsed email)
- Flattens the applicant → application relationship
- No scorecard, interview notes, or activity history
- Manual field remapping required
- Custom pipeline stages don't transfer

**Vendor constraints:** Greenhouse recommends importing no more than 8,000 candidates per batch, accepts a single resume ZIP up to 5 GB, and matches resumes by parsed email. Greenhouse's documentation also indicates that the bulk import method may only be available on certain plan tiers — verify your entitlement before planning around it. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360053674012-Bulk-import-candidates-from-spreadsheet?utm_source=openai))

**Complexity:** Low | **Scalability:** Small datasets only

### 2. API-Based Migration (BambooHR ATS API → Greenhouse Harvest v3)

**How it works:** Write a custom script that reads from BambooHR's ATS API endpoints (`GET /v1/applicant_tracking/applications`, `GET /v1/applicant_tracking/applications/{id}`) and writes to Greenhouse's Harvest v3 API (`POST /v3/candidates`, `POST /v3/attachments`). Each BambooHR application is split into a Greenhouse Candidate + Application pair. ([documentation.bamboohr.com](https://documentation.bamboohr.com/reference/get-applications))

**When to use:** Mid-to-large datasets, attachment preservation required, dedicated engineering bandwidth available.

**Pros:**
- Full control over data transformation
- Can preserve attachments (download from BambooHR, base64-encode, upload to Greenhouse)
- Handles custom field mapping
- Deterministic validation and repeat runs

**Cons:**
- BambooHR's ATS API has no true bulk export endpoint — you must paginate through applications and fetch attachments individually
- BambooHR doesn't officially publish rate limits (approximately 100 requests per minute per API key based on community reports)
- Greenhouse Harvest v3 enforces rate limits on a fixed 30-second window, returning HTTP 429 with a `Retry-After` header
- In v3, user attribution is carried through the OAuth token's `sub` claim, replacing the `On-Behalf-Of` header used in legacy v1/v2 ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/authentication))
- In v3, attachments require an `application_id` — you cannot upload files until the application record exists ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/reference/post_v3-attachments))
- Greenhouse list endpoints require site-admin authorization — a job-admin token can break your lookup phase even if writes succeed ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/list-endpoints?utm_source=openai))

**Complexity:** High | **Scalability:** Enterprise-grade with proper rate limit handling

### 3. Middleware/Integration Platforms (Zapier, Make)

**How it works:** Configure triggers and actions via a no-code platform (e.g., "New Application in BambooHR" → "Create Candidate in Greenhouse").

**When to use:** Ongoing sync of *new* applications only — not historical migration.

**Pros:**
- No code required
- Fast setup for forward-looking sync

**Cons:**
- Cannot handle historical bulk migration
- No attachment transfer support
- Limited field mapping flexibility
- Zapier's Greenhouse integration explicitly warns about limitations with high job counts and strict API rate limits ([help.zapier.com](https://help.zapier.com/hc/en-us/articles/8496064709133-How-to-get-started-with-Greenhouse-on-Zapier))
- BambooHR's webhooks are employee-change oriented, not ATS-event oriented — ATS sync usually requires polling or scheduled exports ([documentation.bamboohr.com](https://documentation.bamboohr.com/docs/event-based-webhooks))

**Complexity:** Low | **Scalability:** Not viable for bulk migration

### 4. Custom ETL Pipeline

**How it works:** Build an extract-transform-load pipeline using staging tables, a transformation layer (dbt, custom code), and the Greenhouse Harvest v3 API for loading. Land raw BambooHR data without mutation, transform deterministically, load in replayable batches, and reconcile at each layer.

**When to use:** Enterprise-scale migrations with complex transformation requirements, or when you need an auditable, repeatable, or incremental pipeline.

**Pros:**
- Fully auditable and repeatable
- Can handle incremental loads and delta syncs
- Best retry behavior and enterprise control
- Integrates with existing data infrastructure

**Cons:**
- Highest engineering investment
- Requires deep familiarity with both APIs
- Easy to overengineer if the dataset is small

**Complexity:** High | **Scalability:** Enterprise

### 5. Managed Migration Service

**How it works:** A specialist team handles extraction, transformation, loading, and validation end-to-end. The service team builds the mapping logic, handles rate limits, encodes attachments, and manages the cutover.

**When to use:** When engineering bandwidth is limited, the dataset is complex, or you can't afford migration errors.

**Pros:**
- Fastest time to completion
- Built-in handling for edge cases (duplicates, missing emails, attachment encoding)
- Validation and rollback planning included
- No engineering overhead on your team

**Cons:**
- External cost
- Requires sharing API credentials (ensure vendor has appropriate security posture)

**Complexity:** Low (for you) | **Scalability:** Any size

### Comparison Table

| Method | Complexity | Attachments | History | Scalability | Engineering Effort | Best For |
|---|---|---|---|---|---|---|
| CSV Export/Import | Low | Partial (ZIP) | ❌ | Small | None | Quick-and-dirty, <500 records |
| API-Based Script | High | ✅ | Partial | Enterprise | 2–4 weeks | Teams with dedicated devs |
| Zapier/Make | Low | ❌ | ❌ | Not viable | None | Forward-only new-record sync |
| Custom ETL | High | ✅ | ✅ | Enterprise | 3–6 weeks | Data-eng-heavy orgs |
| Managed Service | Low (for you) | ✅ | ✅ | Any | None | Most teams |

### Which Approach for Your Scenario?

- **Small business, <500 candidates, no attachment requirements:** CSV export/import. Accept the data loss on history.
- **Mid-market, 500–10,000 candidates, need attachments:** API-based script if you have a senior engineer with 2+ weeks of availability. Managed service otherwise.
- **Enterprise, 10,000+ candidates, complex custom fields:** Managed service or custom ETL. The rate limit math alone makes DIY painful at scale.
- **Ongoing sync (keeping both systems temporarily):** Zapier/Make for forward-only new applications. API webhook-based sync for bidirectional requirements.

## When to Use a Managed Migration Service

Build in-house when you have a senior engineer available for 3+ weeks, a small dataset, and straightforward field mapping. For everyone else, the hidden costs of DIY are significant:

- **Rate limit engineering.** Greenhouse's Harvest v3 API enforces limits on a fixed 30-second window. At enterprise scale, your script spends more time sleeping than working. Building a proper rate limiter with exponential backoff, `Retry-After` header parsing, and concurrency control is non-trivial.
- **Attachment encoding overhead.** Every resume must be downloaded from BambooHR (separate API call per file ID), base64-encoded, and uploaded to Greenhouse with the correct `content_type`. A 5MB resume becomes ~6.7MB after base64 encoding. Multiply by thousands of candidates.
- **Deduplication.** BambooHR applicants who applied to multiple jobs may appear as separate records. Greenhouse expects a single Candidate with multiple Applications. Your script must detect and merge these by email, then by name+phone as a fallback.
- **Dependency ordering.** In v3, attachments require an `application_id`. Applications require a `candidate_id` and `job_id`. Jobs must exist first. If any step fails mid-batch, your script needs idempotent restart logic to avoid creating duplicates.

Even Greenhouse's own documentation treats historical migration and active candidate migration as separate tracks — that's exactly how experienced providers run these projects. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360040034991-Active-candidate-migration))

For teams evaluating external providers, judge on four criteria:

1. Can they split flat BambooHR applicant rows into Greenhouse candidates and applications without losing history?
2. Can they preserve notes and sequence attachments correctly?
3. Can they map custom fields and stage crosswalks explicitly?
4. Can they provide reconciliation, not just a success message?

**ClonePartner's approach:** We handle the flat-to-relational transformation (BambooHR applicant → Greenhouse Candidate + Application), base64-encode all attachments, respect rate limits automatically, and set up the post-migration reverse sync back to BambooHR's HRIS. We've done this across [1,200+ migrations](https://clonepartner.com/blog/blog/how-we-run-migrations-at-clonepartner/) — the pattern is well-tested.

## Pre-Migration Planning: Auditing BambooHR Data

Before touching any API, audit what you actually have:

- [ ] **Total candidate count** — Active, hired, rejected, and archived. BambooHR's ATS reporting can surface these numbers.
- [ ] **Attachment inventory** — How many candidates have resumes? Cover letters? Other documents? Each file ID is a separate API call to download.
- [ ] **Custom fields** — List all custom fields on applications and applicants. Map each to a Greenhouse candidate custom field, application custom field, or determine if it can be dropped.
- [ ] **Pipeline stages** — Document your current BambooHR statuses (both system-defined and custom). These must be mapped to Greenhouse's configurable job stages. ([documentation.bamboohr.com](https://documentation.bamboohr.com/reference/get-statuses-2))
- [ ] **Duplicate candidates** — Same person, multiple applications. BambooHR may store these as separate applicant records.
- [ ] **Data quality** — Missing emails, incomplete names, inconsistent phone number formats. Clean before migration, not after.
- [ ] **Job openings** — Active and closed. Decide if historical closed jobs need to exist in Greenhouse.
- [ ] **Scope** — Active candidates only, full history, or date-bounded? BambooHR's applications endpoint supports a `newSince` filter, useful for delta loads before final cutover. ([documentation.bamboohr.com](https://documentation.bamboohr.com/reference/get-applications?utm_source=openai))
- [ ] **Unused data** — Old test applications, spam submissions, incomplete applications with no meaningful data.

For a comprehensive audit framework, see our [HRIS data migration checklist](https://clonepartner.com/blog/blog/hris-data-migration-checklist/).

### Migration Strategy

| Strategy | Best For | Risk Level |
|---|---|---|
| **Big bang** | Small datasets, low recruiting volume, clean data | Medium — all-or-nothing |
| **Phased** | Large datasets, multiple departments | Low — migrate by department/job family |
| **Incremental** | Active recruiting during migration | Low — backfill history first, then sync delta |

For most BambooHR → Greenhouse migrations, **phased by job family** works best: migrate closed/historical jobs first (low risk, validates the pipeline), then active jobs in a single coordinated cutover.

## Data Model & Object Mapping: BambooHR to Greenhouse

This is where most migrations break. BambooHR's ATS is HRIS-first — the applicant model is flat, designed to feed hired candidates into employee records. Greenhouse is ATS-first — relational, structured around evaluation and pipeline management.

### Object-Level Mapping

| BambooHR ATS Object | Greenhouse Object | Notes |
|---|---|---|
| Applicant | **Candidate** | 1:1 for unique individuals. Merge duplicates by email. |
| Application | **Application** | Linked to Candidate + Job in Greenhouse. |
| Job Opening | **Job** | Map job details, department, office. |
| Application Status | **Job Stage** | BambooHR uses fixed + custom statuses; Greenhouse uses configurable stages per job. Build a crosswalk, not a name match. |
| Star Rating | **Note** | No direct scorecard equivalent. Serialize as note text. |
| Application Comment | **Note** | POST to activity feed notes endpoint. |
| Resume (file ID) | **Attachment** | Download from BambooHR, base64-encode, upload. Requires `application_id` in v3. |
| Cover Letter (file ID) | **Attachment** | Same process. Set `type: "cover_letter"`. |
| Hiring Lead | **Recruiter** | Map to Greenhouse user ID. |
| Source | **Source** | Map to Greenhouse source if a match exists; otherwise store in a custom field or note. |

### Field-Level Mapping Decisions

- **Email addresses:** BambooHR stores a single email per applicant. Greenhouse supports multiple typed emails (personal, work). Default to `type: "personal"` unless your data indicates otherwise.
- **Phone numbers:** Same pattern — BambooHR is single-value, Greenhouse supports typed arrays.
- **Application source:** Map to Greenhouse's `source` object on the Application. If the source string doesn't match an existing Greenhouse source, store the original text in a custom field.
- **Custom fields:** BambooHR's custom applicant fields must be mapped to Greenhouse candidate-level or application-level custom fields. Greenhouse custom fields are typed (`short_text`, `long_text`, `single_select`, `multi_select`, `yes_no`, `date`, `number`, `url`, `user`). Picklist values must match exactly or be pre-created in Greenhouse before import.
- **Status mapping:** BambooHR returns both system-defined and custom statuses. Greenhouse expects a target `initial_stage_id` plus any later stage movement you choose to replay. Build a stage crosswalk — do not rely on name matching alone. ([documentation.bamboohr.com](https://documentation.bamboohr.com/reference/get-statuses-2))

> [!NOTE]
> BambooHR's star rating (1–5) has no direct Greenhouse equivalent. Greenhouse scorecards use structured attributes rated as `definitely_not`, `no`, `yes`, `strong_yes`, or `no_decision`. Serialize the star rating as a candidate note with a consistent format (e.g., `"BambooHR Rating: 4/5"`) rather than forcing it into scorecard structure.

## Migration Architecture: Working with Both APIs

### Data Flow

```
BambooHR ATS API (extract)
    ↓
Staging Layer (raw data, no mutation)
    ↓
Transformation (clean, map, split, deduplicate)
    ↓
Greenhouse Harvest v3 API (load)
    ↓
Validation (record counts, field-level checks, sampling)
```

### BambooHR ATS API: Extraction Constraints

- **Endpoints:** `GET /v1/applicant_tracking/applications` returns paginated application lists. `GET /v1/applicant_tracking/applications/{id}` returns full details including resume/cover letter file IDs. ([documentation.bamboohr.com](https://documentation.bamboohr.com/reference/get-applications))
- **Authentication:** HTTP Basic Auth — API key as username, any string as password.
- **Rate limits:** BambooHR does not officially publish rate limits. Community consensus is approximately **100 requests per minute per API key**. The API returns `429 Too Many Requests` when exceeded. Implement exponential backoff.
- **Attachments:** Resume and cover letter are returned as file IDs. Each requires a separate download request. Plan for 2–3x the API call volume if most candidates have attachments.
- **Pagination:** Check for pagination keys on the applications list endpoint. Don't assume single-page responses.
- **Delta loads:** The applications endpoint supports a `newSince` filter, useful for incremental extraction before final cutover.
- **Alternative extraction:** For large datasets or when the API doesn't expose full document history, request a BambooHR account data export package (CSV/XML plus attachments folder) as a supplement or alternative to live API extraction. ([documentation.bamboohr.com](https://documentation.bamboohr.com/reference/reports?utm_source=openai))

### Greenhouse Harvest v3 API: Import Constraints

- **Authentication:** OAuth 2.0. You need a client key and client secret from Greenhouse Dev Center. User attribution in v3 is carried through the token's `sub` claim, replacing the `On-Behalf-Of` header used in v1/v2. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/authentication))
- **Rate limits:** Fixed 30-second window. Monitor `X-RateLimit-Remaining` header proactively. HTTP 429 responses include a `Retry-After` header — always respect it. Legacy v1/v2 documentation shows 50 requests per 10 seconds; do not hard-code the old limit into v3 loaders. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/api-rate-limiting))
- **List endpoints:** Require site-admin authorization. A job-admin token will break your lookup phase for jobs, sources, and applications, even if some writes succeed elsewhere. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/list-endpoints?utm_source=openai))
- **Candidate creation:** `POST /v3/candidates` — may return a truncated response initially. The official recommendation is to poll until the full record is available.
- **Attachments:** In v3, attachments are created on the `/v3/attachments` resource and require an `application_id`. You cannot upload files until the application record exists. This dictates your load order: jobs → candidates → applications → attachments. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/reference/post_v3-attachments))
- **Pagination:** Cursor-based with RFC-5988 `Link` headers. Max `per_page` value is **500**. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/pagination))
- **Custom fields:** Must be pre-created in Greenhouse before import. Values are validated on write — mismatched picklist values will fail.

## Step-by-Step Migration Process

### Step 1: Extract from BambooHR

```python
import requests
import time

BAMBOO_DOMAIN = "yourcompany"
BAMBOO_API_KEY = "your_api_key"
BAMBOO_BASE = f"https://{BAMBOO_DOMAIN}.bamboohr.com/api/gateway.php/{BAMBOO_DOMAIN}/v1"

def bamboo_get(endpoint, params=None):
    """GET with basic auth and rate limit handling."""
    resp = requests.get(
        f"{BAMBOO_BASE}{endpoint}",
        auth=(BAMBOO_API_KEY, "x"),
        params=params,
        headers={"Accept": "application/json"}
    )
    if resp.status_code == 429:
        wait = int(resp.headers.get("Retry-After", 60))
        time.sleep(wait)
        return bamboo_get(endpoint, params)
    resp.raise_for_status()
    return resp.json()

# Fetch all applications
applications = bamboo_get("/applicant_tracking/applications")

# Fetch full details for each
for app in applications:
    detail = bamboo_get(f"/applicant_tracking/applications/{app['id']}")
    # detail includes resume_file_id, cover_letter_file_id
    # Store raw JSON for staging
```

### Step 2: Transform and Deduplicate

Key transformations:

1. **Deduplicate candidates** — Group BambooHR applications by email address. Each unique email becomes one Greenhouse Candidate. For applicants without email, use phone + name as a fallback key, or assign a placeholder email for cleanup.
2. **Split into Candidate + Application** — For each group, create one Candidate payload and N Application payloads.
3. **Map statuses to stages** — Build a lookup table mapping BambooHR application statuses to Greenhouse job stage IDs.
4. **Download and encode attachments** — Fetch each file by ID from BambooHR, base64-encode the binary content.
5. **Map custom fields** — Transform BambooHR custom field values to match Greenhouse custom field key/value format. Ensure picklist values exist in Greenhouse.

### Step 3: Prepare Greenhouse Target Environment

Before loading any data:

- Create or confirm jobs, departments, offices, and custom fields in Greenhouse.
- Pre-create all picklist values for custom fields.
- Generate Harvest v3 OAuth credentials in the Greenhouse Dev Center.
- Verify your token has site-admin permissions for list endpoints.

### Step 4: Load into Greenhouse

```python
import requests
import time

GH_BASE = "https://harvest.greenhouse.io/v3"
GH_TOKEN = "your_oauth_bearer_token"

def gh_post(endpoint, payload):
    """POST with rate limit handling."""
    resp = requests.post(
        f"{GH_BASE}{endpoint}",
        headers={
            "Authorization": f"Bearer {GH_TOKEN}",
            "Content-Type": "application/json"
        },
        json=payload
    )
    if resp.status_code == 429:
        wait = int(resp.headers.get("Retry-After", 30))
        time.sleep(wait)
        return gh_post(endpoint, payload)
    if resp.status_code in (200, 201):
        return resp.json()
    else:
        log_error(endpoint, payload, resp.status_code, resp.text)
        return None

# Create candidate with application
candidate_payload = {
    "first_name": "Jane",
    "last_name": "Doe",
    "email_addresses": [{"value": "jane@example.com", "type": "personal"}],
    "phone_numbers": [{"value": "+1-555-0100", "type": "mobile"}],
    "applications": [{
        "job_id": 12345,
        "initial_stage_id": 67890  # From your stage crosswalk
    }]
}
result = gh_post("/candidates", candidate_payload)
candidate_id = result["id"]
```

Load order matters: **jobs first, then candidates with applications, then notes, then attachments.** In v3, attachments require the `application_id`, so they must come after applications are created and confirmed.

### Step 5: Upload Attachments

```python
import base64

def upload_attachment(application_id, file_bytes, filename, file_type="resume"):
    encoded = base64.b64encode(file_bytes).decode("utf-8")
    payload = {
        "application_id": application_id,
        "filename": filename,
        "type": file_type,
        "content": encoded,
        "content_type": "application/pdf"
    }
    return gh_post("/attachments", payload)
```

Validate the exact payload shape against current v3 endpoint documentation before production use. The attachment API surface changed between v1/v2 and v3.

### Step 6: Rebuild Relationships and Notes

After all candidates and applications are created:

- Import notes/comments as activity feed entries
- Set application stages using the appropriate v3 stage-advancement endpoints
- Verify Application → Job linkages

### Step 7: Validate

See the Validation section below.

> [!TIP]
> Build an error log from day one. Capture: BambooHR source ID, Greenhouse target ID (if created), HTTP status, error message, `Retry-After` value, and timestamp. This is your debugging lifeline when records fail at 3 AM.

## Edge Cases & Challenges

### Attachment Encoding at Scale

Base64 encoding inflates file sizes by ~33%. A 5MB resume becomes ~6.7MB in the POST body. Migrating 10,000 candidates with resumes means pushing ~67GB of API payload data through Greenhouse's rate-limited API. Plan your migration timeline around this math.

### Missing Email Addresses

Greenhouse strongly expects an email address on each candidate. BambooHR allows applications without email. Two options: skip these records (document and report) or create a placeholder email format (e.g., `applicant-{bamboo_id}@migration-placeholder.internal`) and tag them for manual cleanup.

### Duplicate Candidates Across Jobs

BambooHR may store the same person as separate applicant records if they applied to different jobs. Before creating Greenhouse Candidates, group by email, then by name+phone as a fallback, then by LinkedIn URL if available. Create one Candidate with multiple Applications.

Greenhouse can auto-merge imported candidates when criteria match via bulk import, but referral and agency imports are excluded from auto-merge. Don't rely on Greenhouse's auto-merge as a substitute for deterministic dedup logic in your pipeline. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360053674012-Bulk-import-candidates-from-spreadsheet))

### Custom Field Type Mismatches

BambooHR custom fields may be free-text while the corresponding Greenhouse field expects a picklist value. Pre-create all valid options in Greenhouse before migration, or map to a text-type custom field to avoid validation errors on write. For more on this pattern, see [5 "Gotchas" in ATS Migration](https://clonepartner.com/blog/blog/ats-migration-gotchas/).

### Notes vs. Structured Evaluations

Greenhouse v3 exposes scorecard read endpoints but no straightforward write path for rebuilding historical evaluations. Free-text BambooHR comments and star ratings are safer as notes than as attempted scorecard recreation. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/reference/get_v3-scorecards?utm_source=openai))

### API Failures and Retries

Both APIs can fail transiently. Build your pipeline with idempotency in mind — maintain a mapping table of `bamboo_app_id → greenhouse_candidate_id` so you can restart without creating duplicates. Use exponential backoff with jitter for 429 and 5xx errors.

## Limitations & Constraints of the Harvest API

Be honest about what you'll lose or compromise:

- **No scorecard import.** Greenhouse scorecards are tightly coupled to interview plans and job stages. You cannot bulk-import historical scorecards via the API. BambooHR's star ratings are best preserved as candidate notes.
- **No activity feed backfill.** Greenhouse's activity feed (the timeline on a candidate's profile) is system-generated. You can add notes, but you can't recreate the full activity history with original timestamps.
- **Stage history is limited.** You can set a candidate's current stage, but bulk-importing the full stage progression history isn't supported via the API. Greenhouse's active candidate migration guidance says you cannot backdate an interview, though you can backdate a scorecard. If exact interview chronology matters, preserve it as note history. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360040034991-Active-candidate-migration))
- **Audit trail resets.** All API-created records show the authenticated user as the creator. Original creation timestamps from BambooHR can be stored in a custom field, but they won't appear in Greenhouse's native UI as the true creation date.
- **List endpoints require site-admin.** This affects discovery, lookups, validation, and any post-load reconciliation that reads through list endpoints. ([harvestdocs.greenhouse.io](https://harvestdocs.greenhouse.io/docs/list-endpoints?utm_source=openai))
- **Bulk import plan availability.** Greenhouse's documentation indicates bulk import may only be available on certain plan tiers. Verify your entitlement before designing around it. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/360053674012-Bulk-import-candidates-from-spreadsheet?utm_source=openai))
- **BambooHR webhooks are employee-oriented.** If you need ongoing ATS deltas from BambooHR, you'll likely need to poll `applicant_tracking` endpoints or run scheduled exports — BambooHR's webhooks are not ATS-event-driven. ([documentation.bamboohr.com](https://documentation.bamboohr.com/docs/event-based-webhooks))
- **The BambooHR reverse handoff is picky.** For post-go-live hired-candidate export, office and department values must match exactly, and the job title must already exist in BambooHR. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/201177624-I-use-BambooHR-What-does-the-integration-look-like-How-do-I-enable-it-?mobile_site=true))

For a deeper look at Greenhouse's data model constraints, see our [Greenhouse to Lever migration guide](https://clonepartner.com/blog/blog/greenhouse-to-lever-migration-the-ctos-technical-guide/).

## Validation & Testing

### Record Count Comparison

| Check | Source (BambooHR) | Target (Greenhouse) | Status |
|---|---|---|---|
| Unique candidates | Count distinct emails | `GET /v3/candidates` count | Must match |
| Total applications | Count all applications | `GET /v3/applications` count | Must match |
| Attachments | Count file IDs | Count attachments per application | Must match |
| Jobs | Count job openings | `GET /v3/jobs` count | Must match |

Validate by job and by status, not just total counts. A good UAT set includes one rejected candidate, one hired candidate, one duplicate person with multiple applications, one record with notes, and one record with attachments — for each high-volume job family.

### Field-Level Validation

Sample 5–10% of migrated records. For each:

- Compare first name, last name, email against BambooHR source
- Verify attachment is downloadable and not corrupted
- Check custom field values match source
- Confirm application is linked to the correct job and stage

### UAT Process

1. **Test on a Greenhouse sandbox first.** Greenhouse provides sandbox environments — use them. Never test on production.
2. **Have recruiters spot-check 20–30 candidate profiles** — verify the data looks right in the UI, not just in the API.
3. **Run a Greenhouse pipeline report** (e.g., pipeline per job) and compare against BambooHR's equivalent.
4. **Test the reverse integration** — mark a test candidate as hired in Greenhouse and confirm the data flows to BambooHR.

### Rollback Plan

If the migration fails validation, you can programmatically delete migrated records using the mapping table (`bamboo_id → greenhouse_id`). Keep BambooHR active and read-only during migration — it's your source of truth until cutover.

## Post-Migration: The Reverse HRIS Sync

Most companies migrating ATS data to Greenhouse keep BambooHR as their HRIS. Newly hired candidates in Greenhouse need to flow back into BambooHR as employee records.

Greenhouse has a native BambooHR integration for this purpose. When a candidate is marked as hired in Greenhouse, the integration exports candidate data (name, email, phone, job title, department, start date) to BambooHR to create a new employee record. BambooHR's marketplace listing also notes that candidate documents (resume, cover letter, offer letter) can sync when they were uploaded before export. ([support.greenhouse.io](https://support.greenhouse.io/hc/en-us/articles/201177624-I-use-BambooHR-What-does-the-integration-look-like-How-do-I-enable-it-?mobile_site=true))

Setup requires:

1. Create a dedicated API user in BambooHR with scoped permissions for the fields Greenhouse will write.
2. Generate a BambooHR API key for this user.
3. In Greenhouse, navigate to **Configure > Integrations > BambooHR** and enter the API key and your BambooHR subdomain.
4. **Department and office values must match exactly** between systems. Job titles must pre-exist in BambooHR.

> [!WARNING]
> It is not possible to bulk export candidates from Greenhouse to BambooHR. Each hired candidate must be exported individually from the candidate profile. Plan your post-migration workflow accordingly.

After go-live, plan for these ongoing tasks:

- Rebuild pipeline configuration, templates, sources, and recruiter ownership rules in Greenhouse.
- Train recruiters on Greenhouse's person-versus-application semantics.
- Monitor for duplicate creation and bad stage mappings during the first two to four weeks.
- Keep a scheduled validation report running until recruiters stop finding discrepancies.

## Best Practices for a Zero-Downtime Move

1. **Back up everything.** Export BambooHR data to CSV as a safety net. Store raw JSON responses from BambooHR's API in a durable location (S3, GCS).
2. **Run test migrations first.** Migrate 50–100 candidates to a Greenhouse sandbox. Validate every field, every attachment, every relationship. Fix mapping issues before scaling.
3. **Validate incrementally.** Don't wait until the end to check. Validate after every batch (e.g., every 500 candidates).
4. **Automate validation.** Write a script that compares source and target counts by job and by status, and flags mismatches automatically.
5. **Freeze BambooHR ATS during cutover.** Coordinate with your recruiting team to stop accepting new applications in BambooHR 24–48 hours before the final migration run. This eliminates the delta problem.
6. **Keep BambooHR read-only for 30 days post-migration.** Don't decommission the ATS module until all data is verified in Greenhouse.
7. **Don't trust AI-generated migration scripts blindly.** LLM-generated code often ignores rate limits, retry logic, and edge cases. Use it for boilerplate, but review every line. See our take on [using generative AI in SaaS data migration](https://clonepartner.com/blog/blog/using-generative-ai-in-saas-data-migration/).
8. **Store immutable source keys.** Maintain a persistent `bamboo_app_id → greenhouse_candidate_id` mapping table so reruns are updates, not duplicates.

## Sample Data Mapping Table

This table reflects the default mapping when BambooHR is the source ATS and Greenhouse v3 is the target. Adjust for your custom fields and stage names.

| BambooHR ATS Field | Greenhouse Field | Greenhouse Endpoint (v3) | Type/Notes |
|---|---|---|---|
| `applicant.firstName` | `candidate.first_name` | `POST /v3/candidates` | String |
| `applicant.lastName` | `candidate.last_name` | `POST /v3/candidates` | String |
| `applicant.email` | `candidate.email_addresses [].value` | `POST /v3/candidates` | Array of objects, set `type: "personal"` |
| `applicant.phone` | `candidate.phone_numbers [].value` | `POST /v3/candidates` | Array of objects, set `type: "mobile"` |
| `applicant.address` | `candidate.addresses [].value` | `POST /v3/candidates` | Array of objects, set `type: "home"` |
| `application.jobId` | `application.job_id` | `POST /v3/candidates` (in applications array) | Integer — pre-map to Greenhouse Job ID |
| `application.status` | Job Stage | Stage crosswalk → `initial_stage_id` | Map status → Greenhouse stage ID |
| `application.rating` | Note (body text) | Activity feed notes endpoint | No direct equivalent — serialize as note |
| `application.appliedDate` | `application.created_at` | `POST /v3/candidates` | ISO 8601 datetime |
| `application.resumeFileId` | Attachment | `POST /v3/attachments` | Download → base64-encode → upload. Requires `application_id`. |
| `application.coverLetterFileId` | Attachment | `POST /v3/attachments` | Set `type: "cover_letter"`. Requires `application_id`. |
| `application.comments []` | Notes | Activity feed notes endpoint | One note per comment |
| Custom field (text) | Candidate/Application custom field | `PATCH /v3/candidates/{id}` | Must pre-create in Greenhouse |
| Hiring Lead | Recruiter assignment | Job-level configuration | Map to Greenhouse user ID |

## Automation Script Outline

Here's a structural Python outline for a complete API-based migration:

```python
import json
import time
import base64
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("bamboo_to_greenhouse")

class MigrationEngine:
    def __init__(self, bamboo_config, greenhouse_config):
        self.bamboo = BambooClient(bamboo_config)
        self.greenhouse = GreenhouseClient(greenhouse_config)
        self.mapping = {}  # email -> gh_candidate_id
        self.errors = []

    def run(self):
        # Phase 1: Extract
        logger.info("Extracting applications from BambooHR...")
        applications = self.bamboo.get_all_applications()

        # Phase 2: Deduplicate and group by candidate
        candidates = self.group_by_email(applications)
        logger.info(f"Found {len(candidates)} unique candidates")

        # Phase 3: Load into Greenhouse
        for email, apps in candidates.items():
            try:
                gh_id = self.create_candidate(email, apps)
                self.mapping[email] = gh_id

                for app in apps:
                    app_id = self.create_application(gh_id, app)
                    self.upload_attachments(app_id, app)
                    self.migrate_comments(gh_id, app_id, app)

            except Exception as e:
                self.errors.append({
                    "email": email,
                    "error": str(e),
                    "timestamp": time.time()
                })
                logger.error(f"Failed for {email}: {e}")

        # Phase 4: Report
        self.save_mapping()
        self.save_errors()
        logger.info(
            f"Done. {len(self.mapping)} succeeded, "
            f"{len(self.errors)} failed."
        )

    def group_by_email(self, applications):
        """Group applications by email to identify unique candidates."""
        grouped = {}
        for app in applications:
            email = app.get("email", "").lower().strip()
            if not email:
                email = f"no-email-{app['id']}@placeholder.internal"
            grouped.setdefault(email, []).append(app)
        return grouped

    def upload_attachments(self, application_id, bamboo_app):
        """Download from BambooHR, base64-encode, upload to Greenhouse."""
        for file_type in ["resume", "cover_letter"]:
            file_id = bamboo_app.get(f"{file_type}_file_id")
            if not file_id:
                continue
            file_bytes = self.bamboo.download_file(file_id)
            encoded = base64.b64encode(file_bytes).decode("utf-8")
            self.greenhouse.post(
                "/attachments",
                {
                    "application_id": application_id,
                    "filename": f"{file_type}_{bamboo_app['id']}.pdf",
                    "type": file_type,
                    "content": encoded,
                    "content_type": "application/pdf"
                }
            )
```

This is a structural outline — you'll need to implement `BambooClient` and `GreenhouseClient` wrappers that handle authentication, pagination, and rate limiting as described in the Architecture section. Validate exact payload shapes against current Greenhouse v3 documentation before production use.

## What Comes Next

Migrating from BambooHR's ATS to Greenhouse is a one-way architectural upgrade — you're moving from a flat, HRIS-centric applicant model to a relational, evaluation-focused recruiting platform. The technical challenge is in the translation: splitting applicants into candidates and applications, encoding attachments, respecting rate limits, loading in dependency order, and handling the edge cases that surface at scale.

Get the data model mapping right, and the rest is execution. Get it wrong, and you'll spend weeks debugging broken relationships in production.

The teams that get this right are not the ones with the fanciest scripts. They are the ones that respect the target data model and prove every relationship after load.

If you'd rather not spend your engineering team's time on base64 encoding arithmetic and rate limit retry loops, we've done this before — across BambooHR, Lever, Workable, and other ATS platforms.

> Need help migrating from BambooHR to Greenhouse? Our team handles the full extraction, transformation, and loading — including attachments, custom fields, and the reverse HRIS sync. Book a 30-minute call to scope your migration.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click)

## Frequently asked questions

### Can I export candidate data from BambooHR's ATS via API?

Yes, BambooHR's ATS API provides endpoints to list and retrieve application details, including resume/cover letter file IDs. There's no true bulk export endpoint — you must paginate through applications and fetch attachments individually. Rate limits are approximately 100 requests per minute per API key based on community reports. For large datasets, supplement with a BambooHR account data export package (CSV/XML plus attachments folder).

### Should I use Greenhouse Harvest v1/v2 or v3 for a new migration?

Use Harvest v3 for new work. Greenhouse says v1 and v2 will be unavailable after August 31, 2026. V3 uses OAuth 2.0 and bearer tokens, while legacy versions use Basic auth and On-Behalf-Of headers. In v3, attachments also require an application_id and are created on a separate resource.

### How do I migrate resumes and attachments to Greenhouse?

BambooHR stores resumes as file IDs. Download each file via a separate API call, base64-encode the binary content, and upload to Greenhouse. In Harvest v3, attachments are posted to the /v3/attachments endpoint and require an application_id — the application must exist before you can upload files. Base64 encoding inflates file sizes by ~33%, so plan migration timelines accordingly.

### Does Greenhouse have a native BambooHR integration for post-migration?

Yes, but it works in one direction: exporting hired candidates from Greenhouse to BambooHR to create employee records. It does not support importing historical candidate data into Greenhouse. Each hired candidate is exported individually — bulk export is not supported. Department and office values must match exactly between systems.

### Can I use Zapier to migrate historical data from BambooHR to Greenhouse?

No. Zapier and similar middleware platforms are designed for forward-looking sync of new records, not historical bulk migration. They lack attachment transfer support and can't handle the volume or complexity of a full ATS data migration. BambooHR's webhooks are also employee-oriented, not ATS-event-driven, so ATS sync usually requires polling.
