Greenhouse to Ashby Migration: APIs, Mapping & Rate Limits
Technical guide to migrating from Greenhouse to Ashby. Covers API rate limits, scorecard mapping, the Ashby 200 OK error trap, and attachment URL expiration.
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,200+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
Migrating from Greenhouse to Ashby is a data-model translation problem disguised as a vendor switch. Both systems use a Candidate → Application → Job hierarchy, which makes the migration feel straightforward on the surface. But Greenhouse's structured evaluation layer — scorecards with attribute-level ratings, rigid interview plans, and the explicit prospect/candidate distinction — maps imperfectly onto Ashby's unified, analytics-first architecture.
A naive CSV export flattens your scorecard history, drops pipeline stage timestamps, and leaves you with attachment URLs that expire before anyone downloads them.
The real challenge isn't moving candidate records. It's preserving the interview intelligence your team has accumulated: years of structured feedback, hiring-committee decisions, and source attribution data that powers your recruiting analytics. This guide covers the API constraints on both sides, every viable migration method and its trade-offs, the object-mapping decisions you need to make, and the edge cases that break most DIY attempts.
For context on related ATS migration challenges, see our coverage of common ATS migration gotchas and GDPR/CCPA compliance when moving candidate data.
Greenhouse Harvest API v1/v2 is deprecated and will be removed on August 31, 2026. Any migration scripts you build today should target Harvest v3 (OAuth 2.0). Do not invest engineering time in v1/v2 integrations.
Why Companies Migrate from Greenhouse to Ashby
The drivers typically fall into three categories:
- Analytics consolidation. Greenhouse's reporting is functional but limited. Standard reports cover pipeline metrics, time to hire, and source effectiveness. Custom reporting requires data exports and external tools. Ashby is the only ATS where you can execute on recruiting operations, measure effectiveness, and make improvements based on insights, all without leaving the tool.
- Operational speed. Teams report full implementation in 3 weeks (including HRIS integration), with significantly reduced overhead compared to Greenhouse's configuration model.
- All-in-one simplicity. Ashby unifies ATS, CRM, scheduling, and analytics into a single platform, reducing the integration burden that comes with Greenhouse's hub-and-spoke model of 500+ third-party connectors.
That said, moving between these systems requires navigating genuine structural differences in how they model hiring data.
Core Data Model Differences
Both systems share the same top-level hierarchy: Candidate → Application → Job. The similarities end there.
| Concept | Greenhouse | Ashby |
|---|---|---|
| Evaluation model | Scorecards with attribute-level ratings (5-point scale), focus attributes, overall recommendation | Application feedback forms; structured but less granular |
| Candidate types | Explicit Prospect vs. Candidate distinction | Single candidate type with project/pipeline-based segmentation |
| Interview structure | Interview plans → stages → interview kits → scorecards | Interview plans → stages → interview events → feedback |
| Custom fields | Scoped to Candidates, Applications (Enterprise-only), Jobs, Offers | Scoped to Candidates, Applications, Jobs, Openings; created via API |
| API style | REST (v1/v2 deprecated), REST with cursor pagination (v3, OAuth 2.0) | RPC-style (all POST requests, /CATEGORY.method) |
| Reporting | Basic built-in; relies on external BI tools | Native analytics engine with drill-down custom report builder |
The most significant mismatch is scorecards. Greenhouse scorecards allow your hiring team to review candidates using predetermined criteria. Interviewers use scorecards to submit feedback as part of the structured hiring process. Each scorecard includes individual attribute ratings, private notes, and an overall recommendation. Ashby's application feedback captures structured input but doesn't replicate the per-attribute rating model. Expect to lose fidelity here unless you encode scorecard data as structured notes.
Ashby's terminology also differs from Greenhouse's. What Greenhouse calls an "application," Ashby documents as a job consideration. Greenhouse "scorecards" map to feedback forms. Greenhouse "rejection reasons" become archive reasons. Translate these terms before you build mappings. (docs.ashbyhq.com)
Migration Approaches: Which One Actually Works
Ashby officially supports multiple migration routes, which makes Greenhouse-to-Ashby one of the cleaner ATS pairs on paper. The real decision is how much fidelity you need and how much engineering you can afford. (docs.ashbyhq.com)
Ashby's CSM-Led Native API Migration
How it works: Create a Greenhouse Harvest API key, enable the permissions Ashby requests, configure the 15 Greenhouse webhooks Ashby lists, and let Ashby pull the data. Ashby's docs say this path is supported for Greenhouse and usually completes in 2–3 days depending on data volume. (docs.ashbyhq.com)
What migrates: Ashby explicitly documents migration of departments, offices, jobs, openings, job stages, custom fields, candidates (including notes), users, scorecards, sources, tags, job posts, offers, resumes, rejection reasons, scheduled interviews, degrees, disciplines, EEOC data, email templates, demographic questions, and application form submissions. Emails can also be synced on request. (docs.ashbyhq.com)
Limitations: User permissions are not mapped. Prospect-only Greenhouse records become candidates with lead status and no job consideration. Email templates arrive archived, and placeholder tokens must be updated manually. If users edit migrated data before Ashby disables sync, those edits can be overwritten. Data integrity runs about 98% clean transfer, with the 2% requiring manual cleanup typically involving duplicate records and custom field mappings that don't translate one-to-one. One team reported spending three weeks cleaning up records after two custom fields did not map correctly.
When to use it: Default option for teams not running a highly customized Greenhouse instance.
Complexity: Low (for you). Medium (behind the scenes).
CSV/Flat-File Export
How it works: Export candidate data from Greenhouse using built-in reports or the bulk export feature. Import into Ashby using their bulk import tool.
What you get: Ashby's bulk import supports creating candidate profiles and openings, handles large files in batches, and has no explicit row limit. Their own docs are clear that self-serve CSV import only supports candidate data and is not a comprehensive view of the candidate lifecycle. (docs.ashbyhq.com)
Limitations: CSV exports from Greenhouse flatten relational data. You lose scorecard details, pipeline stage history, interviewer attribution, and attachment links. For a deeper analysis, see our guide on using CSVs for SaaS data migrations.
When to use it: Small teams (<500 candidates) migrating only active pipelines with no need for historical interview data. Never for a full historical migration.
Complexity: Low.
API-Based Custom Migration
How it works: Extract data from the Greenhouse Harvest API, transform it to match Ashby's schema, and load it via Ashby's RPC API. This is the only approach that gives you full control over field mapping, deduplication logic, and scorecard preservation.
At its simplest, this means scripts that read Harvest and write to Ashby. For enterprise datasets, it becomes a full ETL pipeline with a staging database, normalized mapping tables, idempotent loaders, reconciliation reports, and delta catch-up logic.
Key technical detail: Ashby's application.create endpoint accepts createdAt and applicationHistory parameters, which is what makes stage-history backfill practical without losing timeline fidelity. (developers.ashbyhq.com)
Limitations: Both APIs have aggressive rate limits (covered in detail below). The public Ashby API write surface is thinner than what the native migration covers — historical feedback and email backfill may not have public write endpoints. Confirm with Ashby before committing engineering time to a custom scorecard or email backfill path. Expect 2–4 weeks of engineering effort for a production-quality pipeline, plus debugging time for edge cases.
When to use it: Enterprise migrations with >10,000 candidates, complex custom fields, or strict requirements around historical data fidelity.
Complexity: High.
Middleware Platforms (Zapier, Make)
How it works: Use pre-built connectors to sync records via triggers and actions. Ashby's Zapier app exposes triggers like Candidate Applied, Interview Schedule Created, Offer Created, and actions like Create Candidate, Create Candidate Note, Consider Candidate for Job. (zapier.com)
Limitations: No batch processing. Rate limits hit fast. No support for complex transformations like scorecard decomposition. The Ashby Make listing is a community app that requires a third-party access code, which adds a support boundary.
When to use it: Post-cutover sync of new candidates or applications during a parallel-run period. Not suitable as a primary migration method.
Complexity: Low (but low fidelity).
Managed Migration Service
How it works: A migration partner handles API extraction, custom transforms, attachment handling, and validation as one execution plan.
When to use it: Low engineering bandwidth, hard deadlines, complex custom data models, or any move where recruiters cannot stop working.
What to ask for: A written mapping spec, QA checklist, retry strategy, and explicit treatment of resumes, private notes, scorecards, and prospect-only records.
Complexity: Low for your team.
Comparison Table
| Method | Data Fidelity | Scorecards | Attachments | Scale | Engineering Effort | Timeline |
|---|---|---|---|---|---|---|
| Ashby CSM-led | ~98% | Yes (native) | Yes | SMB to enterprise | None | Days |
| CSV export/import | Low | No | No | < 500 candidates | Low | Days |
| Custom API pipeline | Full (if built right) | Depends on public API surface | Yes | Enterprise | High | 2–4 weeks |
| Middleware (Zapier/Make) | Low | No | No | Small batches | Low | Ongoing |
| Managed service (ClonePartner) | Full | Yes | Yes | Enterprise | None | Days |
Which Approach for Your Scenario
- Small team, < 500 candidates, no historical data requirements: Ashby's CSM-led migration or CSV.
- Enterprise, 10K+ candidates, need full scorecard history: Custom API pipeline or managed migration service.
- Parallel-run period (both systems active): API-based bulk migration for historical data; middleware for incremental new-candidate sync.
- No engineering bandwidth: Ashby CSM-led or managed service. Do not attempt a DIY API migration without dedicated developer time.
- Custom mapping or phased coexistence: Custom ETL or managed service.
The dividing line is whether you can live without complete lifecycle history. Ashby's own docs say CSV import is not comprehensive lifecycle migration, while the native API path covers far more. (docs.ashbyhq.com)
Greenhouse Harvest API: Export Constraints and Rate Limits
The Harvest API was designed to allow customers to export their data from Greenhouse. It's the primary extraction path for any API-based migration.
Authentication
Harvest v1/v2 is deprecated and will be removed on August 31, 2026.
- v1/v2: Harvest uses Basic Auth over HTTPS. The username is your Greenhouse API token and the password should be blank.
- v3: OAuth 2.0 with client credentials. All integrations must migrate to Harvest v3.
Rate Limits
Greenhouse enforces rate limits in unusually short windows:
- v1/v2: Typically 50 requests per 10 seconds for approved partners and custom integrations, as specified in the
X-RateLimit-Limitresponse header. - v3: If you exceed the request limit within a 30-second window, the API responds with HTTP 429 Too Many Requests. This limit is applied on a fixed-window basis — the counter resets at the end of each 30-second interval.
These short windows make it easy to accidentally blow through rate limits during bulk operations. For context, pulling 100,000 full candidate records with per-candidate activity feeds has a theoretical floor of about 5.5 hours at 50 requests per 10 seconds — before retries, scorecards, or file downloads.
Pagination
Harvest v3 uses cursor-based pagination for list endpoints. It currently returns only a next link (no prev or last). You can't calculate total result counts or jump to arbitrary pages — iterate forward only.
Use per_page (up to 500) to reduce the number of requests for large datasets. Requests exceeding 500 return a 422 Unprocessable Entity error.
Attachment URLs Expire
This is the single most common failure mode in DIY Greenhouse exports:
Resumes, cover letters, and other document attachments in Greenhouse are hosted on AWS and are provided via signed, temporary URLs. Users should download these documents immediately after the request is made and should not rely on these URLs to be available for future requests.
If you extract candidate records, store the JSON, and come back a week later to download resumes, every URL will be dead. Your migration script must download attachments inline during extraction — not as a separate pass.
Greenhouse attachment URLs are signed AWS S3 links. They expire. Download them during extraction, not after. If your extraction runs across multiple days, re-fetch attachment URLs for older batches.
Write Operations Require On-Behalf-Of
The On-Behalf-Of header is required for write operations (POST, PATCH, DELETE), containing the Greenhouse user ID performing the action for audit trail purposes. If you're writing data back to Greenhouse during a parallel run, hardcoding an invalid user ID will cause silent failures.
Access Is Binary
Access to data in Harvest is binary: everything or nothing. Harvest API keys should be given to internal developers with this understanding and to third parties with caution.
Ashby API Ingestion: The 200 OK Error Trap
The Ashby API is RPC-style, where endpoints follow the form /CATEGORY.method. Most endpoints take POST requests, even for what in a REST-style API would typically be a GET request.
Authentication
Use HTTP Basic Auth. Send your API key as the username and leave the password blank. Make sure the API key has the correct permission scopes — by default, API keys cannot access information stored in private fields on candidates, except on offers. You must explicitly grant the "Allow access to non-offer private fields" permission.
Rate Limits
The API rate limit is 1,000 requests per minute per API key. This is more generous than Greenhouse's limits, but for migrations pushing 50,000+ candidates with applications, notes, and attachments, you'll make hundreds of thousands of API calls. Plan accordingly:
| Records | Est. API Calls | Time at 1,000/min |
|---|---|---|
| 5,000 candidates | ~25,000 | ~25 min |
| 25,000 candidates | ~125,000 | ~2 hours |
| 100,000 candidates | ~500,000 | ~8+ hours |
The Report API has a separate, stricter limit: 15 requests per minute per organization.
The 200 OK Error Trap
This is the edge case that breaks most migration scripts. What would be 4XX errors return HTTP 200 with success being false. Unsuccessful responses include an errorInfo object indicating what went wrong.
From Ashby's documentation, an error response looks like this:
{
"success": false,
"errorInfo": {
"code": "application_not_found",
"message": "Application not found - are you lacking permissions to edit candidates?"
}
}If your migration script checks only response.status === 200 to determine success, you will silently skip failed writes. Every API call must parse the response body and check the success field. DIY AI scripts fail here because LLM-generated code typically checks response.ok and assumes the record was written.
Build a wrapper:
async function ashbyRequest(endpoint, payload) {
const response = await fetch(`https://api.ashbyhq.com/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(API_KEY + ':').toString('base64')}`
},
body: JSON.stringify(payload)
});
const data = await response.json();
// Ashby returns 200 even for errors — always check success
if (!data.success) {
throw new AshbyApiError(endpoint, data.errorInfo || data.errors, payload);
}
return data.results;
}Pagination and Incremental Sync
If moreDataAvailable is true, use the nextCursor in subsequent requests along with the same syncToken. Sync tokens can expire — if you receive a sync_token_expired error, initiate a new full sync. List endpoints return a maximum of 100 results per page.
Data Mapping: Greenhouse Objects to Ashby
Core Object Mapping
| Greenhouse Object | Ashby Equivalent | Notes |
|---|---|---|
| Candidate | Candidate | 1:1. Preserve Greenhouse id as a custom field for reference and idempotency. |
| Prospect | Candidate (in Project/CRM) | Ashby doesn't distinguish prospects. Native migration maps these to candidates with lead status and no job consideration. |
| Application | Application (Job Consideration) | 1:1. Link to candidate + job. Use applicationHistory to preserve stage movement. |
| Job | Job | Map departments, offices, and hiring team. Pre-create stage dictionary. |
| Job Stage | Interview Stage | Stage names may differ; map by position in pipeline. |
| Scorecard | Feedback / Candidate Note | Lossy. See scorecard handling below. |
| Scheduled Interview | Scheduled Interview | Map interviewer assignments and event times. |
| Offer | Offer | Ashby supports offers with approval workflows. |
| Custom Fields | Custom Fields | Create via customField.create, requiring hiringProcessMetadataWrite permission. Pre-create before loading data. |
| Attachment (Resume, Cover Letter) | Candidate File | Upload via candidate.uploadFile with multipart/form-data content type. Download from Greenhouse first. |
| Activity / Note | Candidate Note | Ashby supports HTML-formatted text in candidate notes. Sanitize source HTML. |
| Source | Source | Map Greenhouse source IDs → Ashby source IDs. Pre-create. |
| Rejection Reason | Archive Reason | Normalize labels and pre-create in Ashby. |
| Tag | Candidate Tag | Create tags via API before assigning them. |
Field-Level Mapping
| Greenhouse Field | Ashby Field | Type | Transform |
|---|---|---|---|
candidate.first_name + last_name |
candidate.name |
String | Concatenate |
candidate.email_addresses [] |
emailAddress + alternateEmailAddresses |
Array → String + Array | Primary = first; rest = alternates |
candidate.phone_numbers [] |
phoneNumber |
Array → String | Use primary |
candidate.addresses [] |
primaryLocation |
Object → Object | Map city/state |
candidate.custom_fields |
customFields.setValue |
Varies | Match by field name; create missing fields first |
application.current_stage |
interviewStageId |
ID reference | Resolve stage name → Ashby stage ID |
application.status |
status |
Enum | active→Active, rejected→Archived, hired→Hired |
application.source |
sourceId |
ID reference | Pre-create sources |
application.applied_at |
createdAt |
ISO timestamp | Preserves original chronology |
scorecard.overall_recommendation |
Note / Feedback | Enum → Text | Map definitely_not, no, yes, strong_yes, no_decision |
attachments [].url |
candidate.uploadFile |
Binary | Download immediately from Greenhouse, then upload |
Greenhouse application custom fields are Enterprise-only. Verify availability in your Greenhouse subscription before building extraction logic. (developers.greenhouse.io)
Ashby's customField.setValues is documented for Application, Candidate, Job, and Opening object types. Accepted types include Boolean, Date, String, ValueSelect, MultiValueSelect, Number, ranges, URL, and UUID. Normalize option labels before load, and pre-create every destination field so you fail on unknown values in staging rather than in production. (developers.ashbyhq.com)
Handling Scorecards (The Hard Part)
Greenhouse scorecards are the highest-fidelity data most teams want to preserve. Scorecard attribute ratings use values like definitely_not, no, yes, strong_yes, no_decision. Each scorecard carries the interviewer's name, interview stage, submission timestamp, and private notes.
Ashby's applicationFeedback.submit endpoint accepts structured feedback tied to an application, but doesn't replicate the per-attribute rating model. Your options:
- Map to application feedback — Use Ashby's feedback forms. You lose attribute-level granularity but preserve the overall signal.
- Encode as structured HTML notes — Create a candidate note per scorecard containing attribute names, ratings, overall recommendation, and interviewer name in a machine-readable format. Preserves data for future reference and search.
- Hybrid — Submit the overall recommendation as feedback, and archive the full scorecard detail as a note.
Option 3 is what we recommend for enterprise migrations. It preserves both searchability (via feedback) and auditability (via notes).
Important caveat: The public Ashby API's write surface for historical feedback is thinner than what the native migration covers. If you're building a custom pipeline, confirm with Ashby that your target representation for scorecards is achievable via their public endpoints before committing engineering time.
Pre-Migration Planning
Data Audit Checklist
Before extracting anything, audit your Greenhouse instance:
- Candidates: Total count, active vs. archived. Identify duplicates (same email, different records).
- Applications: Count per status (active, rejected, hired). Identify orphaned applications.
- Jobs: Active, closed, draft. Decide which historical jobs to migrate.
- Scorecards: Count per application. Identify incomplete/empty scorecards.
- Custom fields: List all by object type. Identify unused fields.
- Attachments: Estimate total resume/cover letter count. Plan storage for downloaded files.
- Sources and rejection reasons: Export full lists for pre-creation in Ashby.
- Users: Map Greenhouse user IDs to Ashby user IDs for interviewer/recruiter attribution.
- Security model: Identify private notes, confidential jobs, access roles, archived users.
This is an ATS migration, not a CRM migration. If your internal plans use CRM nouns: "Contacts" means candidates, "Leads" means prospects or lead-status records, "Opportunities" means applications/job considerations, and "Activities" means notes, emails, interviews, stage moves, and feedback. Translate before you build mappings.
Define scope aggressively. Decide whether you're moving only active pipeline, all historical applications, all attachments, all scorecards, and all private notes. Before migration, audit your existing data in Greenhouse. Archive or clean up duplicate candidate records, obsolete job templates, and outdated custom fields before the import.
Migration Strategy
| Strategy | Best For | Risk |
|---|---|---|
| Big bang | Small teams, < 5K candidates | All-or-nothing; rollback is hard |
| Phased | Enterprise; migrate by department or job family | Longer timeline; dual-system coordination |
| Incremental | Parallel run; migrate history first, then sync active candidates | Highest complexity; requires dedup logic |
One team operated both Greenhouse and Ashby simultaneously for active requisitions during a parallel run, verifying workflows, notifications, and data capture before fully cutting over. We recommend this approach for any team with >20 active requisitions.
Ashby's native migration uses Greenhouse webhooks to keep events synced during the project, and Ashby's list APIs support incremental sync via syncToken, so a backfill-plus-delta model is realistic. (docs.ashbyhq.com)
Risk Mitigation
- Back up everything before starting. Export Greenhouse data via API and store raw JSON.
- Run a test migration with a subset (one department or a specific date range) before going full-scale.
- Ensure you have a full backup via Greenhouse's native bulk export tool in addition to API extracts.
Migration Architecture: Extract → Transform → Load
A reliable migration decouples extraction from ingestion using a staging layer. This prevents you from repeatedly hitting Greenhouse rate limits during testing and transformation.
Step 1: Extract from Greenhouse
Use the Harvest API to pull data in dependency order:
- Users — Needed for interviewer/recruiter attribution
- Departments, Offices — Organizational structure
- Jobs (with stages) — Pipeline definitions
- Candidates (with attachments downloaded inline) — Person records with custom fields
- Applications — Link candidates to jobs; include stage history
- Scorecards — Per-application interview feedback
- Scheduled Interviews — Interview events
- Offers — Offer details per application
- Activity Feeds — Per-candidate notes, emails, timeline events (one API call per candidate — budget for the cost)
Store everything in an intermediary database (PostgreSQL, MongoDB, or flat files).
// Extract candidates with Harvest v3 pagination and rate limit handling
let nextUrl = 'https://harvest.greenhouse.io/v3/candidates?per_page=500';
while (nextUrl) {
const response = await fetch(nextUrl, {
headers: { 'Authorization': `Bearer ${OAUTH_TOKEN}` }
});
const remaining = response.headers.get('X-RateLimit-Remaining');
if (parseInt(remaining) < 5) {
const resetAt = response.headers.get('X-RateLimit-Reset');
await sleepUntil(resetAt);
}
const candidates = await response.json();
await storeToStaging(candidates);
// Harvest v3 cursor-based pagination via Link header
const linkHeader = response.headers.get('Link');
nextUrl = parseLinkHeader(linkHeader, 'next');
}Step 2: Transform
Key transformations:
- Name concatenation: Greenhouse stores
first_nameandlast_nameseparately; Ashby'scandidate.createaccepts a singlenamefield. - Email normalization: Greenhouse allows multiple email objects with type labels; Ashby expects a primary
emailAddressplusalternateEmailAddresses. - Stage resolution: Map Greenhouse stage names/IDs to Ashby interview stage IDs. Pre-create the job and interview plan in Ashby first.
- Scorecard encoding: Convert scorecard attribute ratings into structured HTML for candidate notes (see the hybrid approach above).
- Custom field type matching: Greenhouse's
short_text,long_text,single_select,multi_select,number,date,url,usertypes need to map to Ashby'sString,ValueSelect,Number,Date, etc. Theusertype has no direct equivalent — transform to text containing the user's name or email. - Source ID mapping: Build immutable lookup tables for sources, rejection reasons, stages, and user IDs. Store Greenhouse primary keys in destination custom fields like
legacy_greenhouse_candidate_idfor idempotency. - Deduplication: Resolve duplicate candidates (same email, different records) before loading.
Step 3: Load into Ashby
Load in dependency order to maintain relational integrity:
- Departments, Locations —
department.create,location.create - Jobs (with interview plans and stages) —
job.create - Sources, Tags, Rejection/Archive Reasons — Pre-create via API
- Custom Field Definitions —
customField.create - Candidates —
candidate.create - Applications —
application.createwithcreatedAtandapplicationHistory - Custom Field Values —
customFields.setValue - Notes (including encoded scorecards) —
candidate.createNote - Attachments —
candidate.uploadFile(multipart/form-data) - Application Feedback —
applicationFeedback.submit
Error Handling and Retry Logic
Every write to Ashby must:
- Parse the response body (not just the HTTP status)
- Check
data.success === true - Log failures with the original Greenhouse record ID, Ashby endpoint, error code, and payload
- Implement exponential backoff on rate limit errors
- Support idempotent retries — use Greenhouse IDs as external references to prevent duplicate creation
async function loadWithRetry(endpoint, payload, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const data = await ashbyRequest(endpoint, payload);
return data;
} catch (error) {
if (error.code === 'rate_limit_exceeded' && attempt < maxRetries) {
await sleep(Math.pow(2, attempt) * 1000);
continue;
}
logger.error({
endpoint,
attempt,
greenhouseId: payload._sourceId,
error: error.message
});
throw error;
}
}
}For production pipelines, add rate limiters at the HTTP client level using a library like Bottleneck:
import Bottleneck from 'bottleneck';
const greenhouseLimiter = new Bottleneck({
reservoir: 50,
reservoirRefreshAmount: 50,
reservoirRefreshInterval: 10_000 // 50 requests per 10 seconds
});
const ashbyLimiter = new Bottleneck({
reservoir: 1000,
reservoirRefreshAmount: 1000,
reservoirRefreshInterval: 60_000 // 1,000 requests per minute
});Edge Cases That Break Most Migrations
Duplicate Candidates
Greenhouse allows multiple candidate records with the same email. Ashby deduplicates more aggressively. Deduplicate at the person level (not the application level) before loading, and merge activity history from duplicate records into the primary.
Prospects vs. Candidates
Greenhouse has a first-class Prospect type with its own pools and stages. Ashby doesn't have a separate prospect concept. Ashby's native migration turns prospect-only records into candidates with lead status and no job consideration. If you're building a custom pipeline, decide whether to mirror this behavior or intentionally create a lead-stage application. (docs.ashbyhq.com)
Multi-Application Candidates
A single Greenhouse candidate can have applications across multiple jobs. This maps cleanly to Ashby (one candidate, multiple applications), but you must create the candidate first, then create each application separately. Never flatten to one application per candidate.
Expired Attachment URLs
Download attachments inline during extraction. If your extraction runs across multiple days, re-extract attachment URLs for older batches. In the event AWS S3 is experiencing issues, document attachments will not be available in Harvest. Build checkpoint-based resumption so you can restart from where you left off, not from scratch.
Scorecard Data Loss
Greenhouse scorecards include per-attribute ratings that have no direct equivalent in Ashby. If you skip encoding them as notes, you lose the most valuable structured hiring data your organization has collected. This is the #1 reason enterprise teams choose a managed migration.
Custom Field Type Mismatches
Greenhouse's user type custom field (referencing a Greenhouse user) has no direct equivalent in Ashby — transform to text. Currency fields may need to be stored as numbers with a separate currency indicator. Normalize select/picklist option labels before loading.
Stage Drift
Renamed or retired stages in Greenhouse break lookup tables. Version the stage dictionary and keep old-to-new mappings immutable.
Private Data Permissions
Ashby requires explicit API key permissions for private fields and confidential jobs/projects. If your extraction includes private notes or confidential requisitions, configure both Greenhouse and Ashby API keys accordingly. (developers.ashbyhq.com)
API Failures During Long Runs
Network timeouts, 502 Bad Gateway errors, and transient AWS S3 outages happen during multi-hour extraction runs. Your ingestion engine needs idempotent retry logic and checkpoint-based resumption. On Greenhouse 429s, respect the reset/retry headers. On Ashby, retry only when the body-level error is transient and log the vendor request ID where available.
Limitations and Constraints
Some constraints are structural, not script bugs.
Ashby limitations:
- No equivalent of Greenhouse's granular scorecard attribute ratings in the feedback model
- Report API has a separate limit: 15 requests per minute per organization
- No Prospect Pool equivalent — use Projects instead
- API keys cannot access private fields by default; requires explicit permission grant
- Public custom-field write targets are limited to Application, Candidate, Job, and Opening (developers.ashbyhq.com)
- Native migration does not map user permissions (docs.ashbyhq.com)
- Native migration archives email templates; token replacement is manual (docs.ashbyhq.com)
Greenhouse limitations:
- Harvest API access is binary: everything or nothing
- On-Behalf-Of header required for write operations, complicating bidirectional sync
- v3 pagination doesn't support random access or total count queries
- Application custom fields are Enterprise-only (developers.greenhouse.io)
- Attachment URLs are temporary signed AWS links that expire
If you have extra objects in your Greenhouse ecosystem — agency entities, placement contracts, cross-job evaluation artifacts, or anything stored outside the standard candidate/application/job/opening surface — flatten them before loading. The public Ashby write surface does not support arbitrary custom objects.
Validation and Testing
Do not rely on spot-checking.
Record Count Comparison
After migration, compare counts across both systems:
- Total candidates
- Total applications by status (active, rejected, hired)
- Total jobs
- Total notes/activities per candidate (sample)
- Total attachments (spot-check file integrity)
Field-Level Validation
Sample 50–100 records across different scenarios:
- Candidates with 1 application vs. 5+ applications
- Candidates with attachments vs. without
- Applications in different pipeline stages
- Records with custom field values
- Scorecards/feedback entries
- Recent active candidates and old archived candidates
- At least one hired cohort and one prospect cohort
- Every known edge-case cohort (duplicates, confidential jobs, etc.)
UAT Process
- Recruiting team reviews 20+ candidate profiles in Ashby, comparing against Greenhouse
- Verify pipeline stages, interviewer assignments, and historical notes
- Confirm custom field values render correctly
- Test search and filter functionality against migrated data
- Validate that analytics dashboards produce expected metrics
Rollback Planning
Ashby doesn't have a one-click "undo migration" feature. Your rollback plan is:
- Keep Greenhouse active (read-only) until validation is complete
- Maintain the raw extracted JSON as a backup
- If using Ashby's native sync during migration, do not treat Ashby as the editable source of truth until sync is disabled — Ashby documents that migrated data can revert overnight while sync is still active (docs.ashbyhq.com)
- For partial rollbacks, use Ashby's candidate anonymization API to remove bad records
- Keep Greenhouse in read-only mode for at least 90 days post-cutover
Post-Migration Tasks
- Rebuild interview plans and automations. Greenhouse workflows, approval chains, and email templates don't transfer as working configurations. Rebuild them in Ashby's automation system.
- Update email templates. Ashby brings templates over archived with placeholder tokens not replaced. Update them manually. (docs.ashbyhq.com)
- Retrain your team. One team reported that onboarding to the new ATS took longer than expected — migrating data, retraining hiring managers, and configuring workflows consumed about six weeks. Budget for the learning curve. Train users on Ashby vocabulary: job consideration, feedback form, archive reason, opening.
- Monitor for inconsistencies. Run weekly spot checks for the first month. If you used a phased approach, run a final delta catch-up using Ashby's
syncTokenmodel. - Check downstream integrations. Watch for errors with your HRIS (e.g., Workday) and other connected tools in the first two weeks post-launch.
- Decommission Greenhouse. Only after full validation. Keep it in read-only mode for at least 90 days.
For a structured approach to separating migration from implementation, see why data migration isn't implementation.
Best Practices
- Back up before you start. Export all Greenhouse data via API and store raw JSON in version-controlled storage.
- Run test migrations. Migrate a single department first. Validate every field.
- Download attachments inline. Never defer resume downloads. Greenhouse's signed URLs expire.
- Always check
successin Ashby responses. The 200 OK trap will silently drop records. - Map users first. Greenhouse user IDs → Ashby user IDs. This enables correct interviewer and recruiter attribution.
- Pre-create reference data. Sources, tags, rejection/archive reasons, and custom field definitions must exist in Ashby before you start loading candidates.
- Store immutable source IDs. Keep Greenhouse candidate and application IDs as custom fields in Ashby for idempotency and future reference.
- Log everything. Every API call, every success, every failure. You'll need the audit trail.
- Validate incrementally. Check data at every ETL phase (Extract, Transform, Load), not just at the end.
- Don't trust AI-generated scripts without QA. If that temptation is on the table, read why DIY AI scripts fail at data migration.
Automation Script Outline (Node.js)
For teams building this in-house, here's the structural outline of a production migration pipeline:
// migration.js — Greenhouse → Ashby Migration Pipeline
import Bottleneck from 'bottleneck';
const { GreenhouseClient } = require('./greenhouse');
const { AshbyClient } = require('./ashby');
const { Logger } = require('./logger');
const { Transformer } = require('./transform');
const ghLimiter = new Bottleneck({ reservoir: 50, reservoirRefreshAmount: 50, reservoirRefreshInterval: 10_000 });
const ashbyLimiter = new Bottleneck({ reservoir: 1000, reservoirRefreshAmount: 1000, reservoirRefreshInterval: 60_000 });
async function migrate() {
const gh = new GreenhouseClient({ token: process.env.GH_TOKEN, limiter: ghLimiter });
const ashby = new AshbyClient({ apiKey: process.env.ASHBY_KEY, limiter: ashbyLimiter });
const log = new Logger('migration-run');
// Phase 1: Extract (with inline attachment downloads)
log.info('Phase 1: Extracting from Greenhouse...');
const users = await gh.extractAll('users');
const departments = await gh.extractAll('departments');
const jobs = await gh.extractAll('jobs');
const candidates = await gh.extractAllWithAttachments('candidates');
const applications = await gh.extractAll('applications');
const scorecards = await gh.extractAll('scorecards');
// Phase 2: Transform
log.info('Phase 2: Transforming data...');
const userMap = Transformer.buildUserMap(users, await ashby.listUsers());
const stageMap = Transformer.buildStageMap(jobs, await ashby.listJobs());
const transformedCandidates = candidates.map(c =>
Transformer.candidate(c, userMap)
);
const transformedApps = applications.map(a =>
Transformer.application(a, stageMap, userMap)
);
const encodedScorecards = scorecards.map(s =>
Transformer.scorecardToNote(s, userMap)
);
// Phase 3: Load (in dependency order)
log.info('Phase 3: Loading into Ashby...');
for (const candidate of transformedCandidates) {
const result = await ashby.createCandidate(candidate);
candidate._ashbyId = result.id;
log.success('candidate.create', candidate._sourceId);
}
for (const app of transformedApps) {
const candidate = transformedCandidates.find(
c => c._sourceId === app._candidateSourceId
);
app.candidateId = candidate._ashbyId;
await ashby.createApplication(app);
log.success('application.create', app._sourceId);
}
// Upload attachments, create notes, submit feedback...
log.info('Migration complete. Run validation.');
}
migrate().catch(console.error);This is a structural outline — a production implementation needs comprehensive error handling, checkpoint-based resumption, dead-letter queues for failed records, and reconciliation reports. Expect 2–4 weeks of engineering effort to get this production-ready for an enterprise dataset.
When to Use a Managed Migration Service
Build in-house when you have dedicated engineering bandwidth, a straightforward Greenhouse setup (minimal custom fields, no scorecard preservation requirements), and fewer than 5,000 candidates.
Hire a specialist when:
- You need full scorecard fidelity preserved as structured data
- You have >10,000 candidates with complex custom field mappings
- Your engineering team is already at capacity
- You need the migration done in days, not weeks
- You can't afford the risk of silent data loss from Ashby's 200 OK error pattern
Do not build this in-house just because both vendors have APIs. Greenhouse attachment URLs expire, private data requires deliberate permissions, and Ashby signals application-level failures inside HTTP 200 responses. These are platform behaviors, not hypothetical risks, and they turn migration work into retry logic, audit logging, and UAT management.
At ClonePartner, we've handled ATS migrations where the platform-specific edge cases — expired attachment URLs, non-standard error codes, scorecard decomposition — consumed 70% of the engineering effort. Our scripts handle Ashby's success: false pattern natively, download Greenhouse attachments inline during extraction, and implement intelligent backoff against both platforms' rate limits.
When evaluating data migration vs implementation, treating data migration as an afterthought is the fastest way to derail a new ATS launch. Building a custom ETL pipeline pulls your best engineers away from core product work. See how we run migrations for a detailed look at our process.
Frequently Asked Questions
- How long does a Greenhouse to Ashby migration take?
- Ashby's CSM-led native API migration typically completes in 2–3 days depending on data volume. Custom API-based migrations require 2–4 weeks of engineering effort plus validation time. Smaller teams using CSV can finish in days but lose scorecard and attachment data.
- Does Ashby preserve Greenhouse scorecards during migration?
- Ashby's native migration explicitly includes scorecards. However, Ashby's feedback model does not replicate Greenhouse's per-attribute scorecard ratings. For custom API migrations, scorecards can be preserved by encoding them as structured HTML notes on candidate profiles, or by mapping overall recommendations to Ashby feedback forms.
- What are the Greenhouse and Ashby API rate limits?
- Greenhouse Harvest enforces approximately 50 requests per 10 seconds (v1/v2) or per 30-second window (v3). Ashby enforces 1,000 requests per minute per API key, with a separate Report API limit of 15 requests per minute. For large migrations (50K+ candidates), the load phase alone can take several hours.
- Why do Greenhouse attachment URLs break during migration?
- Greenhouse hosts resumes and attachments on AWS S3 via signed, temporary URLs. These URLs expire shortly after the API request. Any migration script must download attachments immediately during extraction — never as a deferred batch job.
- What is the Ashby 200 OK error trap?
- Ashby's API returns HTTP 200 status codes even for failed requests, with a 'success: false' flag in the JSON response body. Migration scripts that only check HTTP status codes will silently skip failed writes. Always parse the response body and check the success field.