v1 - Stable

Public REST API

Drive Enrichabl from your own code. Create pipelines, push leads, run AI enrichment and email validation, and pull results - all over a simple JSON API.

https://api.enrichabl.com/v1JSON over HTTPSBearer token auth600 req / min / token

Quickstart

From zero to your first enriched lead in five steps.

  1. 1
    Generate a token
    On your profile page, scroll to Personal API Tokens, click Generate token, and copy the value. We don't store the plaintext, so this is your one chance to see it.
  2. 2
    Add provider API keys (if using AI)
    For AI / multi-AI columns, add the corresponding provider key (OpenAI, Gemini, etc.) under Settings → API Keys. Email validation needs BounceBan and/or Mailveri. Firecrawl needs a Firecrawl key.
  3. 3
    Create a pipeline
    POST /v1/pipelines with a name. You'll get a pipeline_id you'll use everywhere else.
  4. 4
    Upload leads
    POST /v1/pipelines/:id/leads with a JSON array. Only email is required. Up to ~5,000 leads per request (4 MB body cap), max 3 concurrent uploads per account.
  5. 5
    Enrich and pull results
    POST /v1/enrichment/start kicks off a background job. Poll /v1/enrichment/jobs/:job_id/progress until COMPLETED, then read enriched values back via GET /v1/pipelines/:id/leads.
bash
# Hello-world: list your pipelines
curl https://api.enrichabl.com/v1/pipelines \
  -H "Authorization: Bearer eab_live_..."

Authentication

Every /v1/* request requires a Personal Access Token in the Authorization header.

bash
Authorization: Bearer eab_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Personal Access Tokens are not the same as the keys on the API Keys page. The "API Keys" page stores third-party credentials (OpenAI, Gemini, BounceBan, etc.) used by the enrichment workers. Personal Access Tokens authenticate you when calling Enrichabl's own API. Generate them under Profile → Personal API Tokens.

Token lifecycle

  • Tokens are shown once, on creation. We store only a SHA-256 hash, so a lost token cannot be recovered - revoke and regenerate.
  • Revoking a token takes effect immediately - the next request returns 401.
  • The first 16 characters (e.g. eab_live_ab12cd34) are stored and shown in the UI so you can identify a token without revealing the secret.
  • last_used_at updates on every successful request.
  • Tokens carry the same permissions as your account. There are no read-only or scoped tokens in v1.

Rate limits & quotas

LimitValueWhen you'll hit it
Requests / token600 / minuteTight loops; chunked exports of millions of rows. Returns 429.
Body size / request4 MB~5,000 typical leads per upload. Chunk larger lists.
Concurrent bulk uploads3 per accountBeyond that → 429 until one finishes.
Lead listing page size10,000 max (default 1,000)Use offset for the next page.
Lead quota - Free100 leadsUpload past it → 402.
Lead quota - Starter30,000 leadsSame.
Lead quota - ProUnlimited (1M hard ceiling)For all practical purposes, unlimited.

Quotas count across all your pipelines, not per pipeline. There is no per-pipeline cap.

Errors

Errors are JSON: {"error": "Human-readable message"}. Status codes:

CodeMeaning
400Invalid body, missing required field, unknown enum value
401Missing, malformed, or revoked token
402Lead quota exceeded - body includes current_count / quota_limit
403Subscription gate (read-only mode, missing provider API key, etc.)
404Resource doesn't exist or isn't owned by your token's user
429Rate-limit or concurrent-bulk-ops cap
500Server error - retry with exponential backoff
For privacy, accessing a resource owned by another user returns 404, not 403. We don't reveal whether a given UUID exists.

Pipelines

Create a pipeline

POST /v1/pipelines

bash
curl -X POST https://api.enrichabl.com/v1/pipelines \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "pipeline_name": "Q3 outbound",
    "pipeline_description": "SaaS leads from Apollo",
    "pipeline_color": "#8B5CF6"
  }'

Required: pipeline_name. Optional: pipeline_description, pipeline_color. Returns the full pipeline including pipeline_id.

List your pipelines

GET /v1/pipelines

bash
curl https://api.enrichabl.com/v1/pipelines -H "Authorization: Bearer $TOKEN"

Each pipeline is returned with computed stats: total_leads, imported_leads, validated_leads, enriched_leads, valid_emails, etc.

Get a single pipeline

GET /v1/pipelines/:id

Leads

Upload leads

POST /v1/pipelines/:id/leads

Body: a JSON array. email is the only required field. custom_fields is a free-form object that's stored verbatim on each lead.

bash
curl -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/leads \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "email": "ada@example.com",
      "first_name": "Ada",
      "last_name": "Lovelace",
      "company": "Analytical Engines",
      "title": "Founder",
      "linkedin_url": "https://linkedin.com/in/adalovelace",
      "company_website": "https://analyticalengines.com",
      "custom_fields": { "source": "Apollo", "tier": "A" }
    },
    { "email": "alan@bletchley.test" }
  ]'

Supported fields

FieldNotes
email *Required. Used for dedup.
first_name, last_nameString
company, title, phoneString
linkedin_url, company_websiteString. Used by Firecrawl as url_column source.
company_description, company_size, industry, locationString
custom_fieldsObject. Stored as JSONB. AI/Firecrawl outputs are also written here.

Behaviour

  • Response code is 202 Accepted with a summary of created / skipped / failed per row.
  • Dedup is by email, scoped to your account. Re-uploading an email that already exists in any of your pipelines is silently skipped - not an error.
  • The handler enforces your subscription quota atomically (advisory lock). If a batch would exceed the cap, it's truncated to fit, and the response reports how many were dropped.
  • Two batches uploaded simultaneously by the same user are serialized at the quota check, so you can't race past the limit.
For 20k+ leads: chunk into ~4,000-lead batches (each ~700 KB). Five sequential calls ingest 20,000 leads in under 2 seconds end-to-end. Parallelism beyond 3 hits the concurrent-bulk-ops cap (429).

List leads (paginated)

GET /v1/pipelines/:id/leads?limit=1000&offset=0

bash
curl "https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/leads?limit=10000&offset=0" \
  -H "Authorization: Bearer $TOKEN"
json
{
  "data": [
    {
      "pipeline_lead_id": "…",
      "pipeline_lead_stage": "IMPORTED",
      "pipeline_lead_email_validation_status": "VALID",
      "lead": {
        "lead_id": "…",
        "lead_email": "ada@example.com",
        "lead_company": "Analytical Engines",
        "lead_custom_fields": { "source": "Apollo", "tier": "A" },
        "lead_enrichment_status": "ENRICHED"
      }
    }
  ],
  "pagination": {
    "limit": 10000,
    "offset": 0,
    "total": 23104,
    "has_more": true
  }
}
  • limit - clamped to [1, 10000]. Default 1000.
  • offset - must be ≥ 0. Default 0.
  • Result is sorted by pipeline_lead_created_at DESC with a stable tiebreaker on pipeline_lead_id, so pages don't repeat or skip rows even after rapid bulk inserts.
  • Each row is a pipeline_leads junction record with the underlying lead object nested under lead.

Columns

Columns control what shows up alongside each lead in the UI and drive what enrichment writes back. There are four kinds.

TypeUse for
baseA lead field already on the record (e.g. industry, company_size).
aiA single-value AI-generated column (one prompt → one output).
multi_aiA multi-field AI column (one prompt → many output fields).
firecrawlWeb-scraped data from a URL on each lead.

All four use the same endpoint:

POST /v1/pipelines/:id/columns

Base columns

bash
curl -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/columns \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "type": "base", "key": "industry" }'

AI columns

You can either reference an existing template by ID, or create one inline.

bash
# Inline (creates the template + attaches it in one call)
curl -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/columns \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "type": "ai",
    "template": {
      "name": "Company summary",
      "prompt_template": "Summarize {{company}} in one sentence.",
      "model": "gpt-4o-mini",
      "max_tokens": 200
    }
  }'

# Or reference an existing template
curl -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/columns \
  -d '{ "type": "ai", "template_id": "<ai_column_template_id>" }'
The provider endpoint URL is auto-detected from the model name. Pass gpt-4o-mini → OpenAI. Pass gemini-2.5-flash → Gemini. Pass sonar-pro → Perplexity. You only need to set endpoint_url manually for custom OpenAI-compatible deployments.

Supported models

ProviderModels
OpenAIgpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, o1, o1-mini, o3, o3-mini, o4-mini
Geminigemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-2.0-flash, gemini-2.0-flash-lite
Perplexitysonar, sonar-pro, sonar-reasoning, sonar-reasoning-pro
Watch the model names exactly - gemini-2.0-flash-exp and other variants are not in the allow-list and will be rejected. Use gemini-2.0-flash.

Multi-AI columns

Multi-AI templates produce several fields from one prompt, parsed as JSON. Create the template first via the dedicated endpoint, then attach a column per output field.

bash
# 1. Create the multi-AI template
curl -X POST https://api.enrichabl.com/v1/columns/multi-ai-templates \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "name": "CompanyProfile",
    "output_fields": ["headquarters_city", "founded_year", "industry"],
    "prompt_template": "For {{company}}, return JSON: {\"headquarters_city\":\"…\",\"founded_year\":\"…\",\"industry\":\"…\"}",
    "model": "gpt-4o",
    "max_tokens": 300
  }'
# Response: { "multi_ai_id": "...", ... }

# 2. Attach a column per output field
curl -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/columns \
  -d '{ "type": "multi_ai", "template_id": "<multi_ai_id>", "field": "headquarters_city" }'

Inline multi-AI templates aren't supported - always create the template first, then attach.

Firecrawl columns

Firecrawl scrapes a URL on each lead and uses its built-in LLM to extract structured fields. Create a template (so the column shows up in the UI), then run enrichment with per-job extraction config.

url_column is the lead-field suffix only. Pass "company_website", not "lead_company_website". It also matches keys inside lead_custom_fields if your URL is stored there.
bash
# 1. Create the template (saves it for UI display)
curl -X POST https://api.enrichabl.com/v1/columns/firecrawl-templates \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "name": "WebExtract",
    "url_column": "company_website",
    "prompt": "Extract the tagline and main product from this company website.",
    "output_fields": ["tagline", "main_product"]
  }'
# Response: { "firecrawl_id": "...", ... }

# 2. Attach a column per output field
curl -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/columns \
  -d '{ "type": "firecrawl", "template_id": "<firecrawl_id>", "field": "tagline" }'

Where enriched values land

Every enriched value is stored on the lead inside lead_custom_fields (read it back from GET /v1/pipelines/:id/leads, under lead.lead_custom_fields). The key naming follows a strict convention:

Column typeCustom-field key
ai{template_name}
multi_ai{template_name}: {field}
firecrawlFirecrawl: {field}
Firecrawl keys use the literal string "Firecrawl: ", not your template name. If you create a Firecrawl template called "WebExtract" with output field tagline, the value lands at lead_custom_fields["Firecrawl: tagline"] - not lead_custom_fields["WebExtract: tagline"].

Enrichment

Run enrichment on the whole pipeline, the top N most-recent leads, or a specific list.

Three ways to target leads

bash
# Whole pipeline
curl -X POST https://api.enrichabl.com/v1/enrichment/start \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "pipeline_id": "'$PIPELINE_ID'", "enrichment_type": "AI_COLUMN", "ai_template_id": "..." }'

# Top 100 most-recent leads in the pipeline
curl -X POST https://api.enrichabl.com/v1/enrichment/start \
  -d '{ "pipeline_id": "'$PIPELINE_ID'", "limit": 100, "enrichment_type": "AI_COLUMN", "ai_template_id": "..." }'

# Specific leads
curl -X POST https://api.enrichabl.com/v1/enrichment/start \
  -d '{ "lead_ids": ["<id1>", "<id2>"], "enrichment_type": "AI_COLUMN", "ai_template_id": "..." }'
enrichment_type is uppercase. Use AI_COLUMN, MULTI_AI_COLUMN, or FIRECRAWL_EXTRACT. Lowercase values are rejected with {"error":"invalid enrichment_type"}.

Per-type body shapes

AI_COLUMN

json
{
  "pipeline_id": "...",
  "enrichment_type": "AI_COLUMN",
  "ai_template_id": "<ai_column_template_id>"
}

MULTI_AI_COLUMN

json
{
  "pipeline_id": "...",
  "enrichment_type": "MULTI_AI_COLUMN",
  "multi_ai_template_id": "<multi_ai_id>"
}

FIRECRAWL_EXTRACT

The extraction config goes in the request body, not the template. The template only exists for UI display.

json
{
  "pipeline_id": "...",
  "enrichment_type": "FIRECRAWL_EXTRACT",
  "firecrawl_extract_config": {
    "url_column": "company_website",
    "prompt": "Extract the tagline and main product.",
    "output_fields": ["tagline", "main_product"]
  }
}

Tracking jobs

bash
# Poll progress
curl https://api.enrichabl.com/v1/enrichment/jobs/$JOB_ID/progress \
  -H "Authorization: Bearer $TOKEN"

# Cancel
curl -X POST https://api.enrichabl.com/v1/enrichment/jobs/$JOB_ID/cancel \
  -H "Authorization: Bearer $TOKEN"

Progress response includes:

  • status - PENDING, IN_PROGRESS, COMPLETED, FAILED, CANCELLED
  • progress_percentage, processed_leads, total_leads, failed_leads
  • completed_lead_ids / failed_lead_ids
  • lead_errors - per-lead error messages on failed rows
Provider API keys must already be configured in Settings → API Keys before enrichment. The job won't even start without them - you'll get a 400 with a clear error like "No API key found for OPENAI". Add the key, then retry.

Email validation

Same target modes as enrichment: whole pipeline, top N, or specific lead IDs.

bash
# Whole pipeline
curl -X POST https://api.enrichabl.com/v1/validation/start \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "pipeline_id": "'$PIPELINE_ID'", "service": "BOUNCEBAN" }'

# Top 50, with Mailveri
curl -X POST https://api.enrichabl.com/v1/validation/start \
  -d '{ "pipeline_id": "'$PIPELINE_ID'", "limit": 50, "service": "MAILVERI" }'

# Specific leads, with Hybrid (BounceBan + Mailveri)
curl -X POST https://api.enrichabl.com/v1/validation/start \
  -d '{ "lead_ids": ["<id>"], "service": "HYBRID" }'

# Poll / cancel
curl https://api.enrichabl.com/v1/validation/jobs/$JOB_ID/progress -H "Authorization: Bearer $TOKEN"
curl -X POST https://api.enrichabl.com/v1/validation/jobs/$JOB_ID/cancel -H "Authorization: Bearer $TOKEN"

Services

serviceNotes
BOUNCEBANReal-time SMTP probing. Fast on corporate domains, often returns UNKNOWN for Gmail/Yahoo (their MX servers block probes).
MAILVERIHeuristic + reputation. Generally faster end-to-end than BounceBan.
HYBRIDBounceBan first; falls back to Mailveri on inconclusive results. Requires both API keys.

Result statuses

Read each lead's outcome from pipeline_lead_email_validation_status: VALID, INVALID, RISKY, or UNKNOWN.

Already-validated leads are skipped on subsequent runs. If you start a MAILVERI job on a pipeline where some leads already have a validation status, those leads are not re-checked, and the job's processed_leads counter only counts the new work. To force re-validation, you'd need to clear the status (UI flow today; not exposed in the v1 API).
Job counters (valid_leads / invalid_leads / risky_leads) only tally definitive outcomes. Leads that come back UNKNOWN are counted in processed_leads but not in V/I/R, so a job can show 100% processed with V+I+R less than total.

Cookbook

Upload 20,000 leads, then enrich the freshest 1,000

bash
# Chunk in batches of 4,000 (each ~700 KB; well under the 4 MB cap)
for offset in 0 4000 8000 12000 16000; do
  jq -n --argjson o $offset '[range($o; $o+4000) | {email: "user\(.)@stresstest.example"}]' \
    | curl -s -X POST https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/leads \
        -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" --data-binary @-
done

# Run AI enrichment on just the 1,000 most-recent leads
curl -X POST https://api.enrichabl.com/v1/enrichment/start \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "pipeline_id": "'$PIPELINE_ID'", "limit": 1000,
        "enrichment_type": "AI_COLUMN", "ai_template_id": "..." }'

Export every lead in a pipeline as JSON

bash
offset=0
while : ; do
  page=$(curl -s "https://api.enrichabl.com/v1/pipelines/$PIPELINE_ID/leads?limit=10000&offset=$offset" \
           -H "Authorization: Bearer $TOKEN")
  jq -c '.data[]' <<< "$page" >> all-leads.ndjson
  more=$(jq -r '.pagination.has_more' <<< "$page")
  [ "$more" = "true" ] || break
  offset=$((offset + 10000))
done

Pagination is stable across rapid bulk inserts (we tiebreak by pipeline_lead_id), so you won't see duplicate or skipped rows even if leads were uploaded milliseconds apart.

Wait for an enrichment job to finish

bash
while : ; do
  resp=$(curl -s https://api.enrichabl.com/v1/enrichment/jobs/$JOB_ID/progress \
           -H "Authorization: Bearer $TOKEN")
  status=$(jq -r .status <<< "$resp")
  jq -r '"\(.status)  \(.processed_leads)/\(.total_leads)"' <<< "$resp"
  case "$status" in COMPLETED|FAILED|CANCELLED) break ;; esac
  sleep 5
done

Versioning

The API is versioned by URL prefix: /v1/*. Backwards-incompatible changes ship under /v2/* with a deprecation window during which both run in parallel.

Not in v1 - planned

  • Webhooks for job completion (today: poll /jobs/:id/progress).
  • Token scopes (read-only / write).
  • Cursor-based pagination for very large pipelines.
  • Inline multi-AI / Firecrawl template creation in POST /v1/pipelines/:id/columns.
  • Force re-validation flag on POST /v1/validation/start.

Endpoint summary

MethodPathPurpose
GET/v1/pipelinesList pipelines (with stats)
POST/v1/pipelinesCreate a pipeline
GET/v1/pipelines/:idGet one pipeline
POST/v1/pipelines/:id/leadsUpload leads (array)
GET/v1/pipelines/:id/leadsList leads (paginated)
POST/v1/pipelines/:id/columnsAdd a column
GET/v1/columns/ai-templatesList AI templates
POST/v1/columns/ai-templatesCreate an AI template
GET/v1/columns/multi-ai-templatesList multi-AI templates
POST/v1/columns/multi-ai-templatesCreate a multi-AI template
GET/v1/columns/firecrawl-templatesList Firecrawl templates
POST/v1/columns/firecrawl-templatesCreate a Firecrawl template
POST/v1/enrichment/startStart an enrichment job
GET/v1/enrichment/jobs/:job_idGet job record
GET/v1/enrichment/jobs/:job_id/progressPoll progress
POST/v1/enrichment/jobs/:job_id/cancelCancel a running job
POST/v1/validation/startStart a validation job
GET/v1/validation/jobs/:id/progressPoll progress
POST/v1/validation/jobs/:id/cancelCancel a running job

Need help? Email alex@code2b.co. Found a bug or undocumented behaviour? Please tell us, accuracy of these docs matters.