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 written. Up to 500 leads the response has the final counts; a bigger list is queued (status: "pending") and GET /migrate/status/{workflow_id} returns its counts.
CRM & Sequencer Sync
API v2 can push a CluGTM 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 (and GET /migrate/status/{workflow_id}) 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. Up to 500 leads the push runs to completion and the response has the final counts. - Poll, for big lists. Above 500 leads the push is queued and the response has
status: "pending". PollGET /migrate/status/{workflow_id}untilstatusiscompletedorfailed.
# 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 CluGTM columns, values are CRM property names, for example {"first_name": "firstname", "company_name": "company"}. Omit to use CluGTM’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 CluGTM 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. For a list of up to 500 leads the push runs inside the request and the counts are final:
{
"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": 25,
"timestamp_ms": 1751404800000
}| Field | Meaning |
|---|---|
workflow_id | A reference for this push. On a queued push, pass it to GET /migrate/status/{workflow_id} |
status | completed when the push ran (per-lead outcomes are in the counts), pending when it was queued (successful and failed are 0), 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 the quote reserved: 0.2 per lead in the list, rounded up |
credits_charged | Credits charged: 0.2 per lead written (successful), rounded up. 0 on a queued push until it finishes |
Lists over 500 leads: queued syncs
A list of more than 500 leads is queued instead of pushed inside the request. The sync endpoint answers at once with status: "pending", successful, failed and credits_charged at 0, and a workflow_id (it starts with crm-bulk-push-). skipped can already count CSV rows that are people already on the list, which are pushed once.
GET /migrate/status/{workflow_id}
Scope: sync:write · Cost: free
Returns the counts so far. Poll it every few seconds until status is completed or failed.
curl https://api.cleanlist.ai/api/v2/migrate/status/WORKFLOW_ID \
-H "Authorization: Bearer clapi_your_api_key"{
"workflow_id": "crm-bulk-push-org_2xyz...-...",
"status": "completed",
"total_leads": 12000,
"successful": 11640,
"skipped": 210,
"failed": 50,
"unpaid": 100,
"credits_charged": 2328,
"timestamp_ms": 1751404800000
}| Field | Meaning |
|---|---|
status | pending while the push runs, then completed or failed |
total_leads | Leads in the push |
successful, skipped, failed | Counts so far, as in the sync response |
unpaid | Leads left unwritten because the credit balance ran out. They aren’t charged |
credits_charged | 0.2 per lead written so far, rounded up |
Once completed, successful + skipped + failed + unpaid equals total_leads. An unknown handle, a handle from another workspace, or one that finished and expired returns 404 not_found.
Cost
Sync costs 0.2 credits per lead, rounded up per call. The quote prices every lead in the list (128 leads quote 26 credits), and those credits are reserved when the push starts. When it finishes you’re charged only for the leads written to the destination, and skipped and failed leads are refunded: in the example above, 121 written leads cost 25 credits. If the push fails as a whole, the reservation is refunded. A queued push holds the quoted credits for its whole run and settles once at the end, so it never costs more than the quote.
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.clugtm.com/plans); the API’s fix text says app.clugtm.com/billing, which doesn’t exist |
internal_error | 502 | The push failed as a whole and was refunded; retry later |
not_found | 404 | GET /migrate/status doesn’t know that workflow_id in this workspace |
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 CluGTM 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.