Bulk account import
Paste many accounts at once — preview, run, and answer 2FA prompts as they park.
Connecting accounts one at a time is fine for a handful. For an agency onboarding dozens, paste them all at once: the importer parses the paste, tells you exactly what will happen before touching anything, then works through the rows in the background — parking any that need a 2FA code until you supply one.
All eight routes are panel-scoped and authenticated like every other CRM route — see the Bulk Import reference for full request and response schemas.
The flow
Preview — no side effects
curl -X POST "$BASE/api/crm/$CRM/import/preview" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "[email protected],hunter2,http://user:pass@host:port\[email protected],hunter3",
"default_platform": "onlyfans"
}'Nothing is written and no login is attempted. You get back what the importer would do: the detected format, which rows are valid, which lane each takes, and precisely why row 47 is bad.
{
"success": true,
"format": "bare",
"delimiter": ",",
"has_header": false,
"columns": ["email", "password", "proxy"],
"total": 2,
"valid_count": 2,
"invalid_count": 0,
"rows": [
{
"row_index": 0,
"email": "[email protected]",
"platform": "onlyfans",
"lane": "password",
"proxy": "host:port",
"has_password": true,
"has_totp_secret": false,
"cookie_fields": [],
"valid": true,
"errors": []
}
]
}Preview never echoes a secret
Passwords and TOTP secrets come back as booleans (has_password,
has_totp_secret), cookies as a list of field names, and the proxy as
host:port only — the credentials are stripped. The paste already crossed the
network once; reflecting it back would double the exposure for no benefit.
Always preview first. It is free, it costs no platform requests, and it is the only chance to catch a malformed paste before logins start.
Create the job
Same body, plus an optional source:
curl -X POST "$BASE/api/crm/$CRM/import/jobs" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"text": "…", "default_platform": "onlyfans", "source": "paste"}'Returns 202 with the job, and dispatches a background worker. Up to
1,000 rows per job by default.
Watch it
Progress arrives on the event stream as import.progress
(coalesced to at most one per second) and finishes with import.complete:
curl -N "$BASE/api/crm/$CRM/events/stream" -H "X-API-Key: $KEY"Or poll:
curl "$BASE/api/crm/$CRM/import/jobs/$JOB_ID" -H "X-API-Key: $KEY" # one job
curl "$BASE/api/crm/$CRM/import/jobs" -H "X-API-Key: $KEY" # historyAnswer the 2FA prompts
Rows whose platform demands a code park rather than fail, with status
needs_2fa. Supply the code and that row finishes:
curl -X POST "$BASE/api/crm/$CRM/import/jobs/$JOB_ID/rows/$ROW_ID/otp" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'This call is synchronous — one platform call, and the response carries the real outcome rather than "queued", because someone is waiting on it.
Parked rows outlive the job
curl "$BASE/api/crm/$CRM/import/pending-2fa?limit=200" -H "X-API-Key: $KEY"Panel-wide, across every job. This is what lets a dashboard say "3 accounts need a 2FA code" long after whoever started the import has closed the tab — a per-job query could not answer that.
Retry and cancel
# Retry one failed row
curl -X POST "$BASE/api/crm/$CRM/import/jobs/$JOB_ID/rows/$ROW_ID/retry" \
-H "X-API-Key: $KEY"
# Stop the whole job
curl -X POST "$BASE/api/crm/$CRM/import/jobs/$JOB_ID/cancel" -H "X-API-Key: $KEY"Lanes
Every valid row is routed down one of two lanes, decided by what you supplied:
| Lane | Triggered by | Behaviour |
|---|---|---|
cookie | Session cookies or a Fansly auth token | Session paste — no login attempt, most reliable |
password | An email/username and password | Full login flow, may park on 2FA |
Prefer cookie rows where you have them, for the same reasons given in
Connect an account.
Row statuses
| Status | Meaning |
|---|---|
pending | Parsed and accepted, not yet claimed |
running | Login in flight |
needs_2fa | Parked — waiting for you to post a code |
needs_2fa_expired | The 2FA window closed and the credential was destroyed; retry restarts the login |
success | Account connected |
failed | Gave up; see the row's error |
invalid | Failed parse validation — never attempted |
skipped | That email is already connected to this panel |
slot_exhausted | Plan account limit reached |
canceled | Job canceled before this row ran |
There is no queued and no complete — success is success. The authoritative
list comes back as row_states on GET /import/jobs/{job_id}.
Endpoints
| Endpoint | What it does |
|---|---|
POST /import/preview | Parse + validate, no side effects |
POST /import/jobs | Create a job → 202 |
GET /import/jobs | Job history |
GET /import/jobs/{job_id} | One job, with rows |
POST /import/jobs/{job_id}/cancel | Stop a running job |
POST /import/jobs/{job_id}/rows/{row_id}/otp | Supply a 2FA code (synchronous) |
POST /import/jobs/{job_id}/rows/{row_id}/retry | Retry one row |
GET /import/pending-2fa | Panel-wide parked rows |
All writes are on the 100/minute sensitive-route limit.
One slot per imported account — and the cap can be overshot
Each connected account consumes a slot. A row that hits the cap ends as
slot_exhausted (not failed) with Account limit reached (N). Buy a slot to add another account. and code: "SLOT_LIMIT".
The check is a read, not a reservation, so up to the lane concurrency (10 cookie /
6 password) rows can all observe the same last free slot and proceed — a panel can
end up slightly over its cap. Check GET /api/crm/{crm_id}/usage first.