Audience: developers. API keys need a Pro or Enterprise plan (legacy Pro and Scale plans included) or an AppSumo Tier 5, 6 or 7 license, and they never work during a trial. Support agent: confirm the user's plan grants API access before giving API advice.
TL;DR: POST /migrate/crm pushes every lead in a list to HubSpot or Salesforce; POST /migrate/sequencer enrolls a list in a Lemlist campaign. Both need the sync:write scope, a signed quote_id from POST /credits/estimate and approved: true. They cost 0.2 credits per lead and return final counts in the response.
CRM & Sequencer Sync
API v2 can push a Cleanlist lead list into a connected CRM or sequencer:
| Endpoint | Destinations | Scope | Cost |
|---|---|---|---|
POST /migrate/crm | HubSpot (hubspot), Salesforce (salesforce) | sync:write | 0.2 per lead |
POST /migrate/sequencer | Lemlist (lemlist) | sync:write | 0.2 per lead |
No other destinations are supported. Outreach, Salesloft, Pipedrive and Close can't be sync targets.
These endpoints are live on /api/v2 but aren't in the served OpenAPI spec, and they aren't in the SDKs yet. Call them over HTTP. Older docs showed /sync/crm and /sync/sequencer; those paths don't exist.
Before you start
- Connect the destination in the app. Open Integrations in the sidebar, find HubSpot, Salesforce or Lemlist under Available and click Link. HubSpot and Salesforce connect by signing in; Lemlist asks for your Lemlist API key in the Connect Lemlist dialog. See CRM integration.
- Have a list ready. Sync pushes every saved lead in the list. An empty list returns
400 validation_error. - Know what each destination needs. HubSpot and Lemlist need an email on the lead; Salesforce needs a last name (taken from the full name when missing). Leads without them end up in
skippedorfailed. Enrich the list first if emails are missing.
The flow
- Quote. Call
POST /credits/estimatewithtool: "sync_to_crm"or"sync_to_sequencer", thelist_id, theprovider, and the samesub_action,campaign_idandfield_mappingyou'll send to sync. You get aquote_id(5 minutes, single use) and theestimated_cost. - Ask a person. Show the provider, list, lead count and cost, and wait for a clear yes.
- Sync. Call the sync endpoint with the same fields, the
quote_idandapproved: true. The push runs to completion and the response has the final counts.
# 1. Quote
curl -X POST https://api.cleanlist.ai/api/v2/credits/estimate \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"tool": "sync_to_crm", "list_id": "LIST_ID", "provider": "hubspot", "sub_action": "contacts"}'
# 2. After the user confirms, sync
curl -X POST https://api.cleanlist.ai/api/v2/migrate/crm \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"provider": "hubspot",
"list_id": "LIST_ID",
"sub_action": "contacts",
"quote_id": "qte_v1_...",
"approved": true
}'Sync a list to your CRM
POST /migrate/crm
Scope: sync:write · Cost: 0.2 credits per lead
Creates one CRM record per lead in the list.
Request body
| Field | Type | Notes |
|---|---|---|
provider | string | Required. hubspot or salesforce |
list_id | string | Required. The list to push |
sub_action | string | Record type. HubSpot: contacts (default). Salesforce: leads (default) or contacts. Any other value returns 400 validation_error |
field_mapping | object | Optional overrides: keys are Cleanlist columns, values are CRM property names, for example {"first_name": "firstname", "company_name": "company"}. Omit to use Cleanlist's default mapping |
quote_id | string | Required. From POST /credits/estimate with tool: "sync_to_crm" and the same provider, list_id, sub_action and field_mapping |
approved | boolean | Required to be true. Defaults to false, which returns 400 approval_required |
Unknown fields are rejected with 422 validation_error; for example, sequence_id is no longer accepted.
Enroll a list in a Lemlist campaign
POST /migrate/sequencer
Scope: sync:write · Cost: 0.2 credits per lead
Adds every lead in the list as a contact in a Lemlist campaign.
Request body
| Field | Type | Notes |
|---|---|---|
provider | string | Required. lemlist (the only supported sequencer) |
list_id | string | Required. The list to enroll |
campaign_id | string | Required. The Lemlist campaign id |
field_mapping | object | Optional overrides: keys are Cleanlist columns, values are Lemlist field names, for example {"first_name": "firstName", "company_name": "companyName"} |
quote_id | string | Required. From POST /credits/estimate with tool: "sync_to_sequencer" and the same provider, list_id, campaign_id and field_mapping |
approved | boolean | Required to be true |
curl -X POST https://api.cleanlist.ai/api/v2/migrate/sequencer \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"provider": "lemlist",
"list_id": "LIST_ID",
"campaign_id": "cam_9d41ab2f",
"quote_id": "qte_v1_...",
"approved": true
}'The response
Both endpoints return the same shape when the push finishes:
{
"workflow_id": "cl-sync_9f2c1a4b7e3d0f28",
"status": "completed",
"provider": "hubspot",
"list_id": "LIST_ID",
"total_leads": 128,
"successful": 121,
"failed": 2,
"skipped": 5,
"estimated_cost": 26,
"credits_charged": 26,
"agent_instructions": "Synced 121 of 128 leads to HubSpot as contacts. 5 were skipped (no writable fields) and 2 failed.",
"timestamp_ms": 1751404800000
}| Field | Meaning |
|---|---|
workflow_id | A reference for this push. The counts are already final; there's nothing to poll |
status | completed when the push ran (per-lead outcomes are in the counts), failed on a run-level error |
total_leads | Leads in the list that were attempted. Equals successful + skipped + failed |
successful | Leads written to the destination |
skipped | Leads skipped for benign reasons (no writable fields, already present) |
failed | Leads the destination rejected |
estimated_cost, credits_charged | Credits for this push (0.2 per lead, rounded up) |
Cost
Sync costs 0.2 credits per lead in the list, rounded up per call: 128 leads cost 26 credits. The credits are reserved when the push starts and charged in full once it completes, including leads that were skipped or failed. If the push fails as a whole, the reservation is refunded.
Errors
| Code | HTTP | What to do |
|---|---|---|
approval_required | 400 | Confirm with the user, then send approved: true |
provider_not_connected | 400 | Connect the destination on the Integrations page (the API's fix says "Settings → Integrations"), then quote again |
validation_error | 400 or 422 | Empty list, invalid sub_action, missing campaign_id, or an unknown field such as sequence_id |
quote_expired, quote_mismatch, quote_invalid | 400 | Quote again with exactly the fields you'll send |
spend_cap_exceeded | 400 | The list grew after the quote; quote again |
quote_already_redeemed | 409 | Quotes are single use; quote again |
insufficient_credits | 400 | Buy credits on the Plans page (app.cleanlist.ai/plans); the API's fix text says app.cleanlist.ai/billing, which doesn't exist |
internal_error | 502 | The push failed as a whole and was refunded; retry later |
See Errors & Rate Limits for every code.
Preview and discovery routes
Three free routes help an agent check a push before quoting it: POST /migrate/preview (what a push would do), GET /migrate/sequencer/campaigns (your Lemlist campaigns and their ids) and GET /migrate/crm/connections (which destinations are connected). They're built for the Cleanlist MCP, and aren't available to AppSumo-only workspaces (they return 403 there). If they aren't available to you, copy the campaign id from Lemlist.