Clean Leads 365 Cleaner API User Guide
Use the Cleaner API to submit a CSV of phone leads, run the Clean Leads 365 cleaning process, and download the clean output file without using the website.
The API supports three job modes. Pick the one that matches your file size and row count:
- Inline mode —
csvContentis sent in the request body. Default limits: 5 MB and 10,000 phone rows per job. Best for small CRM exports. - Presigned upload, small/medium —
POST /uploadsreturns a short-lived MinIO upload URL. Upload the CSV directly, then start a job withuploadId. For uploads with ≤ 10,000 phone rows the job runs as a single cleaner job (same pipeline as inline). Use this when your CSV is larger than 5 MB but has 10k rows or fewer. - Presigned upload, chunked (large) — Same
POST /uploadsflow, but when the uploaded CSV has more than 10,000 phone rows the API automatically creates a chunked parent job and splits the file into 10,000-row child jobs. The parent job is what you poll. Default upload-mode caps: 100 MB and 200,000 phone rows per job.
All three modes use the same POST /jobs and GET /jobs/{jobId} endpoints. For chunked jobs the response contains an extra chunks block and the files.* URLs are merged outputs across all chunks.
Base URL
Branded API route:
https://cleanleads365.com/v1/cleaner
Direct Supabase function URL:
https://xqmnunpjaugghgrmcfud.functions.supabase.co/cleaner-api
All examples below use an environment variable:
export CLEANER_API_BASE="https://cleanleads365.com/v1/cleaner"
Authentication
Every request requires an API key.
Authorization: Bearer cl365_live_xxx
Content-Type: application/json
Set your API key locally:
export CLEANER_API_KEY="cl365_live_xxx"
Do not put API keys in browser code, public repositories, logs, or customer-visible pages. Call this API from your server, backend job, CRM integration, or automation tool.
Check a Single Phone (Real-Time)
POST /check
Use POST /check when you only need to validate one phone number and want the
DNC/carrier result back immediately in the response. This endpoint is for one-off
checks only — for CSV or list cleaning, keep using POST /jobs or the
POST /uploads flow.
Differences vs. POST /jobs:
- No CSV is required.
- No job is created and no polling is needed.
- No file download is involved — the result is the JSON response.
- Billed at 1 clean credit per call (same per-phone rate as list cleaning).
Request:
{
"phone": "+15551234567"
}
phone accepts US numbers in any of these formats: 5551234567, 15551234567,
+1 (555) 123-4567. It is normalized to a 10-digit number before being checked.
Non-US or unparseable input returns 400.
curl example:
curl -sS -X POST "$CLEANER_API_BASE/check" \
-H "Authorization: Bearer $CLEANER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+15551234567" }'
Successful response:
{
"success": true,
"phone": "5551234567",
"valid": true,
"Category": "Clean",
"country": "US",
"region": "CA",
"locale": "Los Angeles",
"carrierInfo": "Example Carrier",
"newReassignedAreaCode": null,
"timeZone": "PT",
"callingWindow": "...",
"utcOffset": "-480",
"doNotCallToday": false,
"callingTimeRestrictions": "...",
"lineType": "Wireless",
"resultCode": "W",
"creditsCharged": 1
}
Category values: Clean (wireless and VoIP-flagged mobile), Wireless (wireless
but not VoIP-flagged), Landline, Blocked, DNC, Malformed, Invalid,
Unknown. valid is false for Invalid and Malformed, true otherwise.
Error responses:
// 400 - phone missing
{ "success": false, "error": "phone is required" }
// 400 - cannot normalize to a 10-digit US number
{
"success": false,
"error": "Invalid phone number. Provide a US phone in 10-digit or +1XXXXXXXXXX format."
}
// 401 - missing or invalid Bearer key
{ "error": "Missing Bearer API key" }
// 402 - account out of clean credits
{
"success": false,
"error": "Insufficient clean credits",
"currentCleanCredits": 0,
"requiredCleanCredits": 1
}
// 502 - upstream validation failure (retry safe; nothing is charged)
{ "success": false, "error": "Upstream validation service returned 503" }
Manual test checklist:
- Valid mobile →
200,success: true,Categoryin {Clean,Wireless},creditsCharged: 1. - Landline number →
200,Category: "Landline",valid: true. - Garbage input (e.g.
"abc") →400withInvalid phone numbermessage. - Missing
Authorizationheader →401. - Account with
clean_credits_balance = 0→402and no debit recorded. POST /jobs,GET /jobs/{id}, andPOST /uploadscontinue to behave exactly as before (regression check).
Create a Cleaning Job
Submit a CSV and identify which column contains phone numbers. You can either send the CSV inline (csvContent) or upload it first with POST /uploads and pass the returned uploadId. The mode (single vs chunked) is chosen automatically from the phone-row count.
POST /jobs
Request fields:
| Field | Required | Description |
|---|---|---|
idempotencyKey | No | Unique key for safe retries. Reusing the same key with the same payload returns the existing job. |
filename | No | Original CSV filename. Defaults to leads.csv (inline) or the upload's filename. |
phoneColumn | Yes | Exact CSV header that contains phone numbers. Matching is case-insensitive. |
csvContent | One of csvContent / uploadId | Full CSV text, including the header row. Inline mode. Up to 5 MB / 10,000 rows. |
uploadId | One of csvContent / uploadId | ID returned by POST /uploads. Up to 100 MB / 200,000 rows; auto-chunks above 10,000 rows. |
Limits
| Limit | Default | Server setting |
|---|---|---|
| Inline CSV request body size | 5 MB | CLEANER_API_MAX_CSV_BYTES |
| Inline phone rows per job | 10,000 | CLEANER_API_MAX_PHONE_COUNT |
| Presigned upload file size | 100 MB | CLEANER_API_MAX_UPLOAD_BYTES |
| Upload phone rows per job | 200,000 | CLEANER_API_MAX_UPLOAD_PHONE_COUNT |
| Chunk size (rows per child job) | 10,000 | CLEANER_API_CHUNK_SIZE |
| Initial concurrent child invocations | 4 | CLEANER_API_CHUNK_CONCURRENCY |
| Upload URL lifetime | 900 seconds | CLEANER_API_UPLOAD_URL_TTL |
Inline mode never chunks — if you need more than 10,000 rows in one job, use the upload flow.
For uploaded CSVs above 10,000 rows the API creates a parent job and one child job per 10,000 phones; you only ever interact with the parent jobId.
The 5 MB inline limit sits below the upstream Supabase Edge Function request body ceiling. Cloudflare Pages proxying does not raise it. For anything bigger, use the upload flow — large jobs are chunked server-side and the parent job aggregates progress and downloads.
Inline example:
curl -sS -X POST "$CLEANER_API_BASE/jobs" \
-H "Authorization: Bearer $CLEANER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "crm-import-2026-05-30-001",
"filename": "crm-leads.csv",
"phoneColumn": "Phone",
"csvContent": "FirstName,LastName,Phone\nJohn,Doe,5551234567\nJane,Doe,5559876543\n"
}'
Successful response:
{
"success": true,
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "queued",
"filename": "crm-leads.csv",
"phoneCount": 2
}
Save jobId. You need it to check status and retrieve download links.
Presigned Upload Flow (Medium and Large Files)
Use this flow when your CSV is larger than 5 MB, when your HTTP client struggles with large JSON bodies, or when you need more than 10,000 phone rows in a single job. Default caps with this flow are 100 MB and 200,000 phone rows.
The same three steps apply whether the upload ends up running as a single job (≤ 10,000 rows) or as a chunked parent job (> 10,000 rows). The chunked split is automatic and transparent: you only ever poll one jobId and download merged outputs.
Step 1 — Request an upload URL
POST /uploads
Request fields (all optional):
| Field | Description |
|---|---|
filename | Original filename. Defaults to leads.csv. |
contentType | MIME type. Defaults to text/csv. |
curl -sS -X POST "$CLEANER_API_BASE/uploads" \
-H "Authorization: Bearer $CLEANER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "large-leads.csv" }'
Response (201 Created):
{
"success": true,
"uploadId": "9b8f8b13-2c1f-4cba-8b3a-3e5fbfb1b9f1",
"uploadUrl": "https://storage.example.com/...signed...",
"method": "PUT",
"expiresAt": "2026-06-01T16:00:00.000Z",
"expiresIn": 900,
"maxBytes": 100000000,
"filename": "large-leads.csv",
"headers": { "Content-Type": "text/csv" }
}
uploadUrl expires after expiresIn seconds and is tied to your account. Do not share it. Each uploadId can only be used to start one cleaning job.
Step 2 — Upload the CSV directly
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: text/csv" \
--data-binary @large-leads.csv
A 200 response means the file is stored. If the URL expired, request a new one.
Step 3 — Start a job from the upload
curl -sS -X POST "$CLEANER_API_BASE/jobs" \
-H "Authorization: Bearer $CLEANER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"uploadId": "9b8f8b13-2c1f-4cba-8b3a-3e5fbfb1b9f1",
"phoneColumn": "Phone"
}'
Response shape matches the inline POST /jobs response. The upload's filename is used unless you also pass filename.
Check Job Status
Poll the job until it is completed or failed.
GET /jobs/{jobId}
Example:
export JOB_ID="6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0"
curl -sS "$CLEANER_API_BASE/jobs/$JOB_ID" \
-H "Authorization: Bearer $CLEANER_API_KEY"
Queued response:
{
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "queued",
"filename": "crm-leads.csv",
"progress": {
"processedNumbers": 0,
"totalNumbers": 2
}
}
Running response:
{
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "running",
"filename": "crm-leads.csv",
"progress": {
"processedNumbers": 5000,
"totalNumbers": 10000
}
}
Completed response:
{
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "completed",
"filename": "crm-leads.csv",
"progress": {
"processedNumbers": 10000,
"totalNumbers": 10000
},
"totalNumbers": 10000,
"wirelessCount": 6420,
"duplicates": 120,
"invalids": 300,
"creditsCharged": 9880,
"files": {
"cleanCsvUrl": "https://signed-storage-url/clean.csv",
"detailedReportUrl": "https://signed-storage-url/detailed_report.csv",
"originalCsvUrl": "https://signed-storage-url/original.csv",
"expiresIn": 3600
}
}
Download links are signed URLs and expire after expiresIn seconds. Poll the job again if a link expires.
Chunked job responses (large uploads)
When the uploaded CSV has more than 10,000 phone rows, the API creates a chunked parent job. GET /jobs/{jobId} returns an extra chunks block while the job is in flight, and the files.* URLs on completion are merged outputs across all chunks (first chunk's header + concatenated data rows).
Cross-chunk deduplication. Before chunking, the API dedupes rows by normalized 10-digit phone across the entire upload. Each phone is processed (and billed) in exactly one chunk, so the credits charged after completion match the upfront balance check. The phoneCount and droppedDuplicates returned by POST /jobs reflect this. If you need to keep every original row, dedupe on your side before uploading.
Bounded fan-out. The API only starts the first few child jobs immediately (defaults to 4, see CLEANER_API_CHUNK_CONCURRENCY). Remaining chunks stay in pending and are picked up by a server-side watchdog roughly every 60 seconds. Expect chunked jobs to take longer than a back-of-envelope N / concurrency estimate suggests.
Running response for a chunked job:
{
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "running",
"filename": "big-leads.csv",
"progress": { "processedNumbers": 42500, "totalNumbers": 95000 },
"chunks": { "total": 10, "completed": 4, "failed": 0 }
}
Completed response for a chunked job uses the same fields as a single job; aggregated counts and merged signed URLs are returned. Polling intervals of 15–30 seconds are recommended for chunked jobs.
Partial success (completed_with_errors)
If at least one chunk succeeds and at least one chunk fails, the parent job finalizes as completed_with_errors instead of completed. Downloads contain only the rows from successful chunks. Treat this as a partial result.
{
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "completed_with_errors",
"filename": "big-leads.csv",
"progress": { "processedNumbers": 85000, "totalNumbers": 95000 },
"chunks": { "total": 10, "completed": 9, "failed": 1 },
"partial": true,
"warning": "1 of 10 chunk(s) failed; downloads contain only the 9 successful chunk(s).",
"totalNumbers": 95000,
"wirelessCount": 57810,
"duplicates": 0,
"invalids": 0,
"creditsCharged": 85000,
"files": {
"cleanCsvUrl": "https://signed-storage-url/clean.csv",
"detailedReportUrl": "https://signed-storage-url/detailed_report.csv",
"originalCsvUrl": "https://signed-storage-url/original.csv",
"expiresIn": 3600
}
}
If you need a fully complete file, resubmit the original CSV (new idempotencyKey) or upload only the missing rows in a new job.
Output Files
Completed jobs can return up to three file URLs:
| Field | Description |
|---|---|
cleanCsvUrl | Clean CSV output containing retained wireless/mobile rows from your original file. |
detailedReportUrl | Detailed cleaner report from the phone validation process. |
originalCsvUrl | Original input CSV stored with the job. |
Failed Jobs
Failed response:
{
"jobId": "6b4d9c5d-5b0d-4b44-a88e-43e8895d6cc0",
"status": "failed",
"filename": "crm-leads.csv",
"progress": {
"processedNumbers": 10000,
"totalNumbers": 10000
},
"error": "Insufficient clean credits. Need 10000, have 4500"
}
Fix the issue shown in error, then submit a new job with a new idempotencyKey.
Common Errors
| HTTP status | Error | Meaning |
|---|---|---|
400 | Either csvContent or uploadId is required | The request did not include CSV text or an upload reference. |
400 | csvContent exceeds ... byte limit | Inline CSV is too large; use the presigned upload flow. |
400 | uploadId not found | The upload id is unknown. |
400 | uploadId does not belong to this API key | The upload was created by a different account. |
400 | uploadId was already used to start a job | Each upload can start one job; create a new one. |
400 | upload has expired | The upload window passed; request a new uploadUrl. |
400 | No file found at upload location... | The PUT upload did not complete before POST /jobs. |
400 | Uploaded file is ... bytes; limit is ... | The uploaded file exceeds the size cap. |
400 | phoneColumn is required | The request did not name the phone column. |
400 | Phone column "..." was not found in CSV headers | phoneColumn does not match a CSV header. |
400 | No valid 10-digit US phone numbers found... | The phone column has no valid US numbers. |
401 | Missing Bearer API key | The Authorization header is missing. |
401 | Invalid API key | The API key was not found or was revoked. |
402 | Insufficient clean credits | The account does not have enough clean credits to start the job. |
404 | Job not found | The job does not exist or belongs to another account. |
409 | idempotencyKey was already used with different request content | Reuse the original payload or use a new idempotency key. |
Idempotent Retries
Use idempotencyKey when your integration may retry after a timeout or network failure.
- Same
idempotencyKeyand same request content: returns the existing job. - Same
idempotencyKeywith different request content: returns409. - No
idempotencyKey: every successful request creates a new job.
Recommended format:
<system-name>-<source-record-id>-<timestamp-or-sequence>
Example:
crm-import-list-8472-001
Downloading the Clean CSV
After the job is completed:
curl -L "<cleanCsvUrl>" -o clean-leads.csv
The clean CSV preserves your original row structure for rows that passed the cleaner.
Polling Recommendation
For small files, poll every 3 to 5 seconds. For larger files, poll every 10 to 30 seconds.
Do not poll more than once per second per job.
Minimal End-to-End Script
#!/usr/bin/env sh
set -eu
: "${CLEANER_API_BASE:?Set CLEANER_API_BASE}"
: "${CLEANER_API_KEY:?Set CLEANER_API_KEY}"
RESPONSE="$(curl -sS -X POST "$CLEANER_API_BASE/jobs" \
-H "Authorization: Bearer $CLEANER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "example-job-001",
"filename": "example.csv",
"phoneColumn": "Phone",
"csvContent": "Name,Phone\nJohn Doe,5551234567\n"
}')"
echo "$RESPONSE"
Use the returned jobId with GET /jobs/{jobId} until the job is complete, then download files.cleanCsvUrl.
