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:

    1. Inline modecsvContent is sent in the request body. Default limits: 5 MB and 10,000 phone rows per job. Best for small CRM exports.
    2. Presigned upload, small/mediumPOST /uploads returns a short-lived MinIO upload URL. Upload the CSV directly, then start a job with uploadId. 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.
    3. Presigned upload, chunked (large) — Same POST /uploads flow, 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:

    1. Valid mobile → 200, success: true, Category in {Clean, Wireless}, creditsCharged: 1.
    2. Landline number → 200, Category: "Landline", valid: true.
    3. Garbage input (e.g. "abc") → 400 with Invalid phone number message.
    4. Missing Authorization header → 401.
    5. Account with clean_credits_balance = 0402 and no debit recorded.
    6. POST /jobs, GET /jobs/{id}, and POST /uploads continue 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:

    FieldRequiredDescription
    idempotencyKeyNoUnique key for safe retries. Reusing the same key with the same payload returns the existing job.
    filenameNoOriginal CSV filename. Defaults to leads.csv (inline) or the upload's filename.
    phoneColumnYesExact CSV header that contains phone numbers. Matching is case-insensitive.
    csvContentOne of csvContent / uploadIdFull CSV text, including the header row. Inline mode. Up to 5 MB / 10,000 rows.
    uploadIdOne of csvContent / uploadIdID returned by POST /uploads. Up to 100 MB / 200,000 rows; auto-chunks above 10,000 rows.

    Limits

    LimitDefaultServer setting
    Inline CSV request body size5 MBCLEANER_API_MAX_CSV_BYTES
    Inline phone rows per job10,000CLEANER_API_MAX_PHONE_COUNT
    Presigned upload file size100 MBCLEANER_API_MAX_UPLOAD_BYTES
    Upload phone rows per job200,000CLEANER_API_MAX_UPLOAD_PHONE_COUNT
    Chunk size (rows per child job)10,000CLEANER_API_CHUNK_SIZE
    Initial concurrent child invocations4CLEANER_API_CHUNK_CONCURRENCY
    Upload URL lifetime900 secondsCLEANER_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):

    FieldDescription
    filenameOriginal filename. Defaults to leads.csv.
    contentTypeMIME 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:

    FieldDescription
    cleanCsvUrlClean CSV output containing retained wireless/mobile rows from your original file.
    detailedReportUrlDetailed cleaner report from the phone validation process.
    originalCsvUrlOriginal 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 statusErrorMeaning
    400Either csvContent or uploadId is requiredThe request did not include CSV text or an upload reference.
    400csvContent exceeds ... byte limitInline CSV is too large; use the presigned upload flow.
    400uploadId not foundThe upload id is unknown.
    400uploadId does not belong to this API keyThe upload was created by a different account.
    400uploadId was already used to start a jobEach upload can start one job; create a new one.
    400upload has expiredThe upload window passed; request a new uploadUrl.
    400No file found at upload location...The PUT upload did not complete before POST /jobs.
    400Uploaded file is ... bytes; limit is ...The uploaded file exceeds the size cap.
    400phoneColumn is requiredThe request did not name the phone column.
    400Phone column "..." was not found in CSV headersphoneColumn does not match a CSV header.
    400No valid 10-digit US phone numbers found...The phone column has no valid US numbers.
    401Missing Bearer API keyThe Authorization header is missing.
    401Invalid API keyThe API key was not found or was revoked.
    402Insufficient clean creditsThe account does not have enough clean credits to start the job.
    404Job not foundThe job does not exist or belongs to another account.
    409idempotencyKey was already used with different request contentReuse 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 idempotencyKey and same request content: returns the existing job.
    • Same idempotencyKey with different request content: returns 409.
    • 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.