BambooHR to Greenhouse Migration: The CTO's Technical Guide
A technical guide for CTOs migrating ATS data from BambooHR to Greenhouse. Covers API constraints, data model mapping, attachment handling, and rate limits.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
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 — which is why a BambooHR to Greenhouse move has to run through the API. 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, GDPR/CCPA compliance during candidate data transfers, and the Lever to Greenhouse migration guide for a deeper look at Greenhouse's target data model.
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)
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)
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)
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)
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)
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-Afterheader - In v3, user attribution is carried through the OAuth token's
subclaim, replacing theOn-Behalf-Ofheader used in legacy v1/v2 (harvestdocs.greenhouse.io) - In v3, attachments require an
application_id— you cannot upload files until the application record exists (harvestdocs.greenhouse.io) - Greenhouse list endpoints require site-admin authorization — a job-admin token can break your lookup phase even if writes succeed (harvestdocs.greenhouse.io)
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)
- BambooHR's webhooks are employee-change oriented, not ATS-event oriented — ATS sync usually requires polling or scheduled exports (documentation.bamboohr.com)
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.
Effort Decomposition: What "2–4 Weeks" Actually Means
The comparison table says "2–4 weeks" for an API-based migration. That range is meaningless without a breakdown of where the time goes:
| Phase | Estimated Effort | What's Involved |
|---|---|---|
| Extraction scripting | 2–4 days | BambooHR API pagination, authentication, attachment download logic, raw data staging |
| Transformation logic | 3–5 days | Flat-to-relational split, deduplication, stage crosswalk, custom field mapping, email/phone normalization |
| Load scripting | 3–5 days | Greenhouse v3 OAuth setup, dependency-ordered creation (jobs → candidates → applications → attachments), rate limiter with backoff |
| Validation & reconciliation | 2–3 days | Record count checks by job/status, field-level sampling, attachment integrity verification, UAT with recruiters |
| Retry & error handling | 2–3 days | Idempotent restart logic, error log parsing, fixing failed records, re-running batches |
| Cutover coordination | 1–2 days | BambooHR ATS freeze, delta extraction, final load, recruiter sign-off |
The total for a mid-market dataset (500–10,000 candidates with attachments) typically lands at 13–22 working days of senior engineer time. The lower bound assumes clean data and few custom fields. The upper bound accounts for messy dedup, extensive custom field mapping, and multiple validation cycles.
For a custom ETL pipeline (the "3–6 weeks" row), add time for staging infrastructure setup, transformation layer configuration, and incremental sync logic on top of the above.
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-Afterheader 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 acandidate_idandjob_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)
For teams evaluating external providers, judge on four criteria:
- Can they split flat BambooHR applicant rows into Greenhouse candidates and applications without losing history?
- Can they preserve notes and sequence attachments correctly?
- Can they map custom fields and stage crosswalks explicitly?
- Can they provide reconciliation, not just a success message?
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)
- 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
newSincefilter, useful for delta loads before final cutover. (documentation.bamboohr.com) - Unused data — Old test applications, spam submissions, incomplete applications with no meaningful data.
For a comprehensive audit framework, see our 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
sourceobject on the Application. If the source string doesn't match an existing Greenhouse source, store the original text in a custom field. Note that candidates imported without propersourceattribution will break Greenhouse's job board sourcing reports — any pipeline or source effectiveness report that relies on accuratesourcevalues will show gaps for migrated records. Decide early whether to map source strings to Greenhouse sources, create new source entries, or accept the reporting gap. - 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_idplus any later stage movement you choose to replay. Build a stage crosswalk — do not rely on name matching alone. (documentation.bamboohr.com)
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/applicationsreturns paginated application lists.GET /v1/applicant_tracking/applications/{id}returns full details including resume/cover letter file IDs. (documentation.bamboohr.com) - 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 Requestswhen 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
newSincefilter, 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)
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
subclaim, replacing theOn-Behalf-Ofheader used in v1/v2. (harvestdocs.greenhouse.io) - Rate limits: Fixed 30-second window. Monitor
X-RateLimit-Remainingheader proactively. HTTP 429 responses include aRetry-Afterheader — 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) - 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)
- 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/attachmentsresource and require anapplication_id. You cannot upload files until the application record exists. This dictates your load order: jobs → candidates → applications → attachments. (harvestdocs.greenhouse.io) - Pagination: Cursor-based with RFC-5988
Linkheaders. Maxper_pagevalue is 500. (harvestdocs.greenhouse.io) - Custom fields: Must be pre-created in Greenhouse before import. Values are validated on write — mismatched picklist values will fail.
- Candidate dedup on bulk import: Greenhouse's bulk CSV import can auto-merge candidates when email addresses match an existing record. Referral and agency imports are excluded from auto-merge. The API does not perform auto-merge — you are responsible for deterministic dedup before writing via the Harvest API.
Data Protection: GDPR and CCPA Considerations
Candidate data is personal data. Moving it between systems triggers obligations under GDPR (if you process EU/EEA candidates) and CCPA (if you process California residents). Don't treat this as a compliance afterthought — address it before extraction begins.
- Lawful basis for processing. Under GDPR, you need a valid legal basis for extracting and re-importing candidate data. Legitimate interest (completing a system migration necessary for business operations) is the most common basis, but document your assessment. CCPA requires a disclosed business purpose.
- Data processor agreements. If a third-party service handles the migration, they are acting as a data processor. Ensure a Data Processing Agreement (DPA) is in place before sharing API credentials or candidate data. This applies to managed migration services, ETL platforms, and any staging infrastructure outside your own environment.
- Data minimization. Don't migrate data you don't need. Archived applicants from five years ago with no legal retention requirement? Leave them behind or anonymize them. The migration is a natural checkpoint to enforce your retention policy.
- Data in transit and at rest. Candidate resumes and personal details will exist temporarily on your migration infrastructure (local disk, staging database, cloud storage) between extraction and load. Encrypt at rest and in transit. Delete staging data after successful migration and validation.
- Right to erasure. If a candidate submitted a GDPR deletion request that was fulfilled in BambooHR, verify that record is excluded from extraction. Don't re-create deleted data in Greenhouse.
- Consent and transparency. You generally do not need to re-obtain consent for a system migration (it's an operational change, not a new purpose), but your privacy notice should cover system changes. If your notice is narrowly scoped, update it.
For a full treatment of compliance requirements during candidate data transfers, see our GDPR & CCPA compliance guide for ATS migrations.
Step-by-Step Migration Process
Step 1: Extract from BambooHR
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 stagingStep 2: Transform and Deduplicate
Key transformations:
- 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.
- Split into Candidate + Application — For each group, create one Candidate payload and N Application payloads.
- Map statuses to stages — Build a lookup table mapping BambooHR application statuses to Greenhouse job stage IDs.
- Download and encode attachments — Fetch each file by ID from BambooHR, base64-encode the binary content.
- 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
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
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.
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.
Common Errors and How to Resolve Them
When you're running thousands of API calls against two systems, errors are inevitable. Here are the ones you'll see most often and what to do about them:
BambooHR Extraction Errors
| Error | Cause | Resolution |
|---|---|---|
429 Too Many Requests |
Exceeded ~100 rpm rate limit | Respect Retry-After header. Implement exponential backoff with jitter. Reduce concurrency. |
404 Not Found on attachment download |
File ID references a deleted or expired attachment | Log the missing file ID, skip the attachment, and flag the candidate for manual review post-migration. |
401 Unauthorized |
API key revoked, expired, or scoped incorrectly | Regenerate the API key in BambooHR. Verify it has ATS-level access, not just HRIS. |
403 Forbidden on ATS endpoints |
API key lacks ATS module permissions | BambooHR API keys can be scoped. Ensure the key has applicant tracking access enabled. |
Empty resume_file_id on records that should have attachments |
Applicant uploaded via a channel that doesn't store file IDs in the API-accessible field | Check if the file exists via the BambooHR UI. If it does, request a full account data export to retrieve it outside the API. |
Greenhouse Import Errors
| Error | Cause | Resolution |
|---|---|---|
429 Too Many Requests |
Exceeded rate limit within the 30-second window | Read Retry-After header and sleep for the specified duration. Do not retry immediately. |
422 Unprocessable Entity — picklist value mismatch |
Custom field value doesn't match any pre-created option in Greenhouse | Pre-create all picklist values before loading. Log the rejected value and the expected options for manual mapping. |
422 Unprocessable Entity — missing required field |
POST /v3/candidates requires at minimum a name or email |
Check for null/empty first name, last name, and email in your transformation layer. Assign placeholder values and tag for cleanup. |
422 on attachment — no application_id |
Attempted to upload attachment before the application record was created | Enforce load order: candidates → applications → attachments. Wait for application creation confirmation before uploading files. |
403 Forbidden on list endpoints |
OAuth token has job-admin scope, not site-admin | Generate a new token with site-admin permissions in Greenhouse Dev Center. List endpoints (jobs, sources, users) require site-admin. |
401 Unauthorized — token expired |
OAuth access token has expired | Implement token refresh logic. Greenhouse OAuth tokens have limited lifetimes. |
Truncated response on POST /v3/candidates |
Greenhouse returns a partial response initially for candidate creation | Poll GET /v3/candidates/{id} until the full record is available before proceeding to application/attachment creation. |
Maintain a structured error log with BambooHR source ID, Greenhouse target ID (if any), HTTP status, response body, and timestamp. After each batch, review errors before proceeding — a pattern of 422s on the same custom field means your mapping is wrong, not that you need to retry.
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 email addresses match via bulk import, but referral and agency imports are excluded from auto-merge. The API does not auto-merge — all dedup must happen in your pipeline before writes. Don't rely on Greenhouse's auto-merge as a substitute for deterministic dedup logic. (support.greenhouse.io)
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.
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)
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.
Concurrent Applications During Migration
If your recruiting team is still accepting applications in BambooHR while the migration is running, candidates who apply on day 3 of a 5-day migration window will be missed by the initial extraction. The newSince filter on BambooHR's applications endpoint handles this — run a delta extraction after the initial bulk load to catch any applications created since the first extraction started. For the final cutover, freeze BambooHR ATS (stop accepting new applications) 24–48 hours before the last run. In practice, "freeze" means disabling job postings, closing application forms, or redirecting applicants to Greenhouse. Coordinate with your recruiting team to ensure no applications are submitted to BambooHR during the final extraction window.
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)
- 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)
- 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)
- BambooHR webhooks are employee-oriented. If you need ongoing ATS deltas from BambooHR, you'll likely need to poll
applicant_trackingendpoints or run scheduled exports — BambooHR's webhooks are not ATS-event-driven. (documentation.bamboohr.com) - 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)
For a deeper look at Greenhouse's data model constraints, see our Greenhouse to Lever migration 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
- Test on a Greenhouse sandbox first. Greenhouse provides sandbox environments — use them. Never test on production.
- Have recruiters spot-check 20–30 candidate profiles — verify the data looks right in the UI, not just in the API.
- Run a Greenhouse pipeline report (e.g., pipeline per job) and compare against BambooHR's equivalent.
- 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)
Setup requires:
- Create a dedicated API user in BambooHR with scoped permissions for the fields Greenhouse will write.
- Generate a BambooHR API key for this user.
- In Greenhouse, navigate to Configure > Integrations > BambooHR and enter the API key and your BambooHR subdomain.
- Department and office values must match exactly between systems. Job titles must pre-exist in BambooHR.
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
- 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).
- Run test migrations first. Migrate 50–100 candidates to a Greenhouse sandbox. Validate every field, every attachment, every relationship. Fix mapping issues before scaling.
- Validate incrementally. Don't wait until the end to check. Validate after every batch (e.g., every 500 candidates).
- Automate validation. Write a script that compares source and target counts by job and by status, and flags mismatches automatically.
- 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.
- Keep BambooHR read-only for 30 days post-migration. Don't decommission the ATS module until all data is verified in Greenhouse.
- 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.
- Store immutable source keys. Maintain a persistent
bamboo_app_id → greenhouse_candidate_idmapping 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 (Pseudocode)
The following is a structural pseudocode outline — not production-ready code. The class names (BambooClient, GreenhouseClient) are stubs that you'll need to implement with authentication, pagination, and rate limiting as described in the Architecture section. Use this as a skeleton for your pipeline design, not as something you can run directly.
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) # Stub — implement with Basic Auth, pagination, rate limiting
self.greenhouse = GreenhouseClient(greenhouse_config) # Stub — implement with OAuth 2.0, rate limiting
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"
}
)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.
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.