Enrichment Submit
The Enrichment Submit endpoint is the primary programmatic entry point for submitting enrichment jobs to the eCore Platform. It accepts either a JSON payload of records or a CSV/Excel file upload, validates the request against your subscription plan and wallet balance, creates a job, deducts the estimated credits up front, and dispatches the job to the asynchronous enrichment pipeline.
The endpoint returns a job_id immediately. Enrichment continues in the background — use the Status endpoint to poll progress and the Results endpoint to fetch enriched data once the job completes.
Overview
This endpoint supports two enrichment categories:
- Contact enrichment — enriches person-level data (email, mobile phone, job title, LinkedIn URL, etc.).
- Account enrichment — enriches company-level firmographics (industry, revenue, employees, NAICS/SIC, etc.). Account submissions may include an optional
criteriaobject to expand each matched account into multiple contact rows.
Base URL
| Environment | URL |
|---|---|
| Production | https://<host>/api/v1/enrichment/submit/ |
| Staging | https://test.ecoreservice.com/backend/api/v1/enrichment/submit/ |
Authentication
Authentication uses a platform-issued Bearer API token passed in the Authorization header. Tokens are validated on every request — expired or revoked tokens are rejected.
Authorization: Bearer <your_token>
Two flavors of token are supported:
- Live tokens — authenticate as a real user account. No per-second rate limit at the endpoint level; bound by wallet balance and plan limits.
- Test tokens — authenticate as a proxy user with a daily usage quota. Exceeding the quota returns a
429.
Inactive or deleted user accounts are rejected with 401. Tokens are issued and managed in the platform admin — do not store tokens in client-side code.
Submit Endpoint
Endpoint: /api/v1/enrichment/submit/
Method: POST
Description: Validate, price, persist, and dispatch a new enrichment job. Returns a job_id and the credits deducted from the wallet.
Request Headers
| Header | Value | Required |
|---|---|---|
| Authorization | Bearer <token> | Yes |
| Content-Type | application/json (JSON mode) or multipart/form-data (file upload mode) | Yes |
| Accept | application/json (recommended) | Optional |
Submission Modes
There are two ways to submit data:
Mode A — JSON Body: the request body is a JSON object containing all parameters, including a data array of record objects. Recommended for programmatic integration.
Mode B — File Upload: the request body is multipart/form-data containing a file field with a CSV or Excel file plus the other parameters as form fields. The endpoint parses the file into records server-side. The data parameter must not be supplied — the parsed file replaces it.
In file-upload mode, fields_to_enrich may be sent as a single comma-separated string in a form field; the server splits it into a list and trims each entry.
Request Body Parameters
| Parameter | Type | Required | Allowed Values / Notes |
|---|---|---|---|
type | string | Yes | contact or account |
level | string | Yes | standard or pro (account does not accept pro) |
data | array of objects | Yes (JSON mode) | 1 to 500 records; each item is a dict of input fields |
file | file | Yes (file mode) | CSV (.csv) or Excel (.xlsx, .xls); ≤ 500 rows after parsing |
fields_to_enrich | array of strings | Yes | Non-empty; allowed names depend on type and criteria — see Allowed Fields |
criteria | object | Optional | Account-only; unlocks contacts-per-account flow — see Account Criteria |
Example cURL Request (JSON Mode, Contact)
curl --location --request POST 'https://test.ecoreservice.com/backend/api/v1/enrichment/submit/' \
--header 'Authorization: Bearer YOUR_API_TOKEN' \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "contact",
"level": "standard",
"fields_to_enrich": ["contact_email", "contact_mobile_phone", "job_title"],
"data": [
{
"first_name": "Jane",
"last_name": "Doe",
"company_name": "Acme Corp",
"contact_linkedin_url": "https://www.linkedin.com/in/janedoe"
}
]
}'
Example cURL Request (File Upload Mode, Account)
curl --location --request POST 'https://test.ecoreservice.com/backend/api/v1/enrichment/submit/' \
--header 'Authorization: Bearer YOUR_API_TOKEN' \
--form 'file=@"/path/to/companies.csv"' \
--form 'type="account"' \
--form 'level="standard"' \
--form 'fields_to_enrich="industry,revenue_range,employee_range,company_linkedin_url"'
Example cURL Request (Account With Contacts-Per-Account)
curl --location --request POST 'https://test.ecoreservice.com/backend/api/v1/enrichment/submit/' \
--header 'Authorization: Bearer YOUR_API_TOKEN' \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "account",
"level": "standard",
"fields_to_enrich": ["industry", "first_name", "last_name", "contact_email"],
"criteria": {
"limit": 5,
"job_level": ["Director", "C-level"],
"job_function": "Sales"
},
"data": [
{ "company_name": "Acme Corp", "company_website": "acme.com" }
]
}'
Successful Response
On success, the endpoint returns HTTP 200 with a JSON body. The job is queued, not finished — poll the Status endpoint to track progress.
| Field | Type | Description |
|---|---|---|
status | string | Always submitted on success |
job_id | UUID string | Unique identifier of the created job; used for status, results, and pause/resume |
estimated_credits | integer | Credits deducted from the wallet at submission time |
message | string | Human-readable confirmation |
Example (Successful Response)
{
"status": "submitted",
"job_id": "9f3e7a2b-7c1d-4f12-8c9a-1b2c3d4e5f60",
"estimated_credits": 240,
"message": "Enrichment job submitted successfully"
}
Enrichment Types: Contact vs Account
| Aspect | Contact | Account |
|---|---|---|
| Input represents | A person | A company |
| Lookup primary keys | email, LinkedIn URL, first+last+company | company name, website |
| Allowed levels | standard, pro | standard only |
criteria honored | No | Yes — optional |
| Multiple output rows per input | No (1 input → 1 output) | Yes when criteria.limit set (1 account → N contacts) |
Contact enrichment matches against DataCore, Snowflake, scraped LinkedIn data, BigQuery, and (at Pro level) external scrapers. Contact submissions may also request company-level fields — those are filled from the matched person's company record.
Account enrichment matches companies via Meilisearch and DataCore lookups and fills firmographic fields. Account is restricted to standard level. When criteria with limit is supplied, the pipeline expands each matched account into up to N contacts that satisfy the supplied filters.
Enrichment Levels: Standard vs Pro
Standard — uses internal sources only (DataCore, Snowflake, BigQuery, scraped LinkedIn snapshots). Lower per-row cost. Suitable for bulk enrichment where coverage from internal databases is sufficient.
Pro — Standard sources plus paid external sources (Netnut LinkedIn scraping, ZeroBounce / AnyMailFinder email validation, Serper Google search fallbacks). Higher per-row cost; better coverage when internal databases lack the contact.
Quick — an internal mode used by other entry points (such as the Pro-Quick contact enrichment flow). The submit endpoint does not accept quick as a level value — the only valid choices on this endpoint are standard and pro. When Quick jobs do run elsewhere on the platform, they skip name-match verification and still-in-company verification entirely; pricing follows the same per-row table as Standard.
Required Identifier Fields per Record
Every record in data must carry at least one identifier the pipeline can match on. Records that fail this check cause the entire submission to be rejected with a 400 citing the offending record number.
For type = "contact", at least one of:
contact_emailcontact_linkedin_urlcompany_namefirst_namelast_name
For type = "account", at least one of:
company_namecompany_website
The more identifiers a record has, the higher the match success rate. A record with email + LinkedIn URL + name + company will match much more reliably than one with only an email — particularly when Pro-level fallbacks are required.
Allowed Fields in fields_to_enrich
Validation rules:
- Must be a non-empty list of field names.
- May be passed as a JSON array OR (in file-upload mode) a single comma-separated string.
- Whitespace around each name is stripped.
- Any field not in the allowed set causes a
400listing every invalid field.
Allowed Contact fields (type = "contact") — Contact requests may also include any Account field, since contact rows expose company data:
first_name, last_name, job_title, job_level, job_function,
contact_email, contact_linkedin_url,
contact_office_street, contact_office_city, contact_office_state,
contact_office_postal_code, contact_office_country,
contact_office_phone, contact_mobile_phone, contact_direct_phone,
contact_office_country_code, contact_office_state_code
Allowed Account fields (type = "account") — Account requests are restricted to account fields unless criteria is supplied (in which case Contact fields are also permitted, applied to the contacts returned for each account):
company_name, company_hq_street, company_hq_city, company_hq_state,
company_hq_postal_code, company_hq_country, company_website,
industry, sub_industry, company_linkedin_url, company_hq_phone,
revenue_range, employee_range, year_founded,
company_hq_country_code, company_hq_state_code,
industry_naics_code, industry_naics_description,
industry_sic_code, industry_sic_description
Account Criteria (Contacts-Per-Account)
The optional criteria object only applies to Account submissions. When provided, the pipeline expands each matched account into multiple contact rows that satisfy the filters supplied.
| Sub-field | Type | Required | Description |
|---|---|---|---|
limit | integer ≥ 1 | Required to activate | Maximum contacts returned per account |
job_level | string or array of strings | Optional | Filter by job level (e.g., Director, C-level) |
job_function | string or array of strings | Optional | Filter by job function (e.g., Sales) |
keyword | string or array of strings | Optional | Free-text keyword filter applied to contact records |
Behavior notes:
- If
limitis missing, null, empty, or zero, the criteria is ignored and the submission behaves as a pure account-only enrichment. - List-typed sub-fields accept either a single string (treated as a one-item list for backwards compatibility) or an array of strings.
- Whitespace-only entries are stripped; if a list becomes empty after stripping, that sub-field is dropped.
- When
criteriawithlimitis in effect, total credits scale bylimit— the per-account cost is multiplied bylimit. - When
criteria.limitis set, Contact fields become valid choices infields_to_enrichand are applied to the returned contact rows.
File Upload Format & Parsing Rules
When using file-upload mode, the file is parsed server-side before validation runs.
- Accepted extensions:
.csv,.xlsx,.xls. Any other extension returns a parsing error. - Files exceeding 500 rows are rejected with an explicit error message stating the file's row count.
- Column headers are normalized: whitespace stripped, lowercased, internal spaces replaced with underscores. For example,
First Namebecomesfirst_name. - Empty cells are converted to empty strings (no nulls).
- The parsed records are validated against the same identifier-presence rules as JSON-mode submissions.
fields_to_enrichmay be sent as a single comma-separated string in form fields; the server splits and trims it.
Validation Order
Validation happens in this deterministic order. The first failure short-circuits and returns its specific error response:
- Authentication — Bearer token must be present and valid.
- File parsing (file mode only) — extension, row count, column normalization.
- Schema validation —
type∈{contact, account};level∈{standard, pro};datais a list of dicts with length 1–500;fields_to_enrichis a non-empty list of strings;criteria(if supplied) conforms to its schema. - Type/level compatibility —
account+prois rejected. - Allowed-fields validation — every entry in
fields_to_enrichmust belong to the allowed set for(type, has_criteria). - Identifier-presence validation — every record must contain at least one allowed identifier.
- Plan-limit validation — record count ≤ user's subscription
enrichment_limit(default50if no active subscription). - Wallet pre-check — wallet balance must be ≥ estimated credit cost.
- Job creation — a job row is created; its UUID becomes the
job_id. - Credit deduction — credits are deducted under a row-level lock; on failure the just-created job is deleted and a
402is returned. - Task dispatch — input data is cached and the asynchronous enrichment task is dispatched to the queue.
Plan Limits & Records-Per-File
Each subscription plan defines an enrichment_limit that caps the number of records permitted in a single submission.
- If the user has no active subscription, a default limit of 50 records applies.
- If the request exceeds the limit, the response is
400witherror,limit, andrequestedfields. - The hard ceiling enforced by the parsing layer is 500 records regardless of plan; plans with higher limits are still capped at 500 per submission.
- To process larger datasets, split the data across multiple submissions.
Credit Pricing & Estimation
All enrichment costs flow through the platform's dynamic pricing configuration (cached for 5 minutes). There are no hardcoded prices.
Per-row pricing categories
| Category | Standard / Quick rate | Pro rate |
|---|---|---|
| Email enrichment | standard_email_cost | pro_email_cost |
| Phone enrichment | standard_phone_cost | pro_phone_cost |
| Account enrichment | account_standard_cost | account_pro_cost (account does not accept Pro on submit) |
Estimation algorithm
- If any phone field is in
fields_to_enrich(contact_mobile_phoneorcontact_direct_phone), the per-row rate is the phone rate. - Else if
contact_emailis infields_to_enrich, the per-row rate is the email rate. - Else if
typeisaccount, the per-row rate is the account rate. - Else (contact without email/phone), the per-row rate defaults to the email rate.
Total = row_count × per_row_rate
When type is account AND fields are not phone/email AND criteria.limit is set, the total is multiplied by criteria.limit (you pay for each contact returned per account).
The estimate is rounded to an integer when surfaced via the API response.
Reconciliation
The upfront estimate may exceed the actual cost. The pipeline reconciles credits at the end of the job — rows that fail to enrich, fail name-match verification, or are flagged as "left the company" have their charges reversed.
Wallet Deduction & Refund Behavior
- Credits are deducted immediately on successful submission, before the asynchronous task starts running.
- Deduction is wrapped in a row-locked transaction (
SELECT … FOR UPDATE) to prevent concurrent double-spend. - If deduction fails (e.g., balance dropped between pre-check and deduction), the just-created job is deleted and a
402error is returned. - During enrichment, rows that fail to enrich or fail post-checks (name-match, still-in-company) have their per-row charges reversed.
- Pause-then-resume re-estimates the remaining work and deducts only the cost for the unprocessed remainder; the original deduction is not refunded but no double-charge occurs.
- Resuming a job whose pause checkpoint is missing or invalid is rejected (
400) to prevent a second full-job charge after a crash. See Pause and Resume.
Error Responses
| HTTP | When | Body Shape |
|---|---|---|
| 400 | Validation failure (schema, fields, identifiers, type/level mismatch, plan limit exceeded, file parse error, unsupported file type) | { "error": "..." } plus contextual fields like limit/requested for plan errors, or DRF field error dict |
| 401 | Missing or invalid Bearer token, or inactive/deleted user | { "message": "Invalid or expired token" } or { "message": "User is inactive or deleted" } |
| 402 | Insufficient wallet balance | { "error": "You require N credits, but only M are available." } |
| 429 | Test-token daily usage cap exceeded | { "error": { "message": "...", "current_usage": N, "daily_limit": M, "resets_at": "midnight UTC" } } |
| 500 | Unexpected server-side failure during job creation/dispatch | Standard DRF/Django error envelope |
Common 400 examples
{ "level": "Account enrichment only supports 'standard'." }
{ "fields_to_enrich": "Invalid fields: ['some_unknown_field']" }
{ "data": "Record 3: needs at least one of ['company_name', 'contact_email', 'contact_linkedin_url', 'first_name', 'last_name']" }
{ "error": "Plan limit exceeded", "limit": 100, "requested": 250 }
{ "error": "Maximum 500 records. File has 712." }
{ "error": "Unsupported format. Use CSV or Excel." }
Job Lifecycle After Submission
Once submitted, a job moves through these stages. Some are conditional (e.g., email validation only runs when contact_email is requested):
- Uploading / Draft — record created, awaiting task pickup.
- Enrichment Started — the asynchronous task has begun.
- Preparing Data — input snapshot stored as
imported_*fields, internal UUIDs assigned. - DataCore lookup — match against eCore's primary contact/account database.
- Scraped DB lookup — match against scraped LinkedIn snapshots.
- BigQuery lookup — match against the BigQuery contact dataset.
- Meilisearch lookup (account only) — fast company name lookup.
- Pro enrichment (Pro only) — Netnut, Serper, ZeroBounce / AnyMailFinder fallbacks.
- Email validation — runs only when
contact_emailis infields_to_enrich; non-deliverable emails are dropped and the row's email charge is reversed. - BigQuery phone lookup — runs only when phone fields are requested.
- Name-match verification — Standard/Pro contact jobs only (skipped in Quick).
- Still-in-company check — Standard/Pro contact jobs only (skipped in Quick).
- Calculating stats — per-field counts (
enriched/partially_enriched/not_enriched). - Reconciling credits — reversal of charges for rows that failed verification.
- Saving results — chunked write to Firestore.
- Saving row results — per-row records saved to relational store for fast resume and audit.
- Completed — status becomes
Enrichment Completed; results are downloadable. - Paused — if the user pauses mid-run, status becomes
Pausedwith a checkpoint. - Failure — unrecoverable error; pipeline writes failure metadata; no further work is done.
Idempotency
The endpoint is not idempotent. Submitting the same payload twice creates two jobs and consumes credits twice.
- Always store the returned
job_idand check its status before retrying. - On network errors that may have succeeded server-side, query the status list endpoint and look for a recently-created job matching your record count before resubmitting.
- The
job_idis a UUID4 generated server-side; clients cannot supply their own.
Rate Limits & Concurrency
- Live API tokens have no per-second rate limit defined at the endpoint level — bound by wallet balance and plan limits.
- Test tokens have a daily usage cap; exceeding it returns a
429. - Multiple jobs can run concurrently for the same user; each job consumes its own credits and has its own
job_id. - Server-side concurrency on credit deduction is protected with a row-level lock (
SELECT … FOR UPDATE) — concurrent submissions cannot overdraw the wallet.
Common Errors & How to Handle Them
| Symptom | Likely Cause | Recommended Fix |
|---|---|---|
401 Invalid or expired token | Token wrong, deleted, or user inactive | Re-issue token via admin; verify user is active |
400 Account enrichment only supports standard. | Sent type=account with level=pro | Use level=standard for account jobs |
400 Invalid fields: [...] | Field name not in allowed set for this type/criteria combo | Cross-check against Allowed Fields; for account+contacts, ensure criteria is provided |
400 Record N: needs at least one of [...] | A record has no email, LinkedIn URL, name, or company | Add at least one identifier to that record before resubmitting |
400 plan limit exceeded | Records exceed plan's enrichment_limit | Split into multiple submissions or upgrade plan |
400 Maximum 500 records. File has X. | File too large | Split the file into chunks of ≤ 500 rows |
400 Unsupported format. Use CSV or Excel. | Wrong file extension | Re-export as .csv, .xlsx, or .xls |
402 You require N credits, but only M are available. | Insufficient wallet balance | Top up the wallet or reduce the submission size |
429 daily usage exceeded | Test token quota hit | Wait until midnight UTC or use a live token |
Status stuck in-progress for hours | Worker processing or external API hang | Check status; if no progress, contact support — pause/resume may recover |
Status reaches paused unexpectedly | User or system requested pause | Resume via /api/v1/enrichment-control/ with action="resume" |
Operational Notes
- Input data is cached in Redis for 1 hour after submission so the worker can pick it up without re-uploading.
- Results are persisted in Firestore in chunks of 300 records per document.
- Per-row results are also persisted in the relational store, keyed by the row's internal UUID, to support resume without losing previously-charged work.
- All UUIDs used internally are stored as 32-character hex strings (no dashes); the external
job_idis the standard UUID format with dashes. - Internal
_*-prefixed fields are stripped before any persistence step that produces user-visible output.
Glossary
| Term | Meaning |
|---|---|
| Job | A single enrichment submission, identified by job_id (UUID) |
| Record | One row of input data (one person or one company) |
| Identifier | A field used to match an input record to data sources |
| Enrichment level | Standard (internal sources) vs Pro (internal + paid external sources) |
| Enrichment type | Contact (people) vs Account (companies) |
fields_to_enrich | The list of field names the pipeline should attempt to fill in |
criteria | Optional contacts-per-account filters used with type=account |
| Wallet | The user's credit balance held in the platform |
| Checkpoint | A snapshot of rows_processed / state used by pause/resume |
imported_* | Frozen snapshot of the caller's original input on each output row |
enrichment_status | enriched, partially_enriched, or not_enriched |
enrichment_source | Which data source produced the enriched values for the row |
Try It Out
Explore the Enrichment Submit API in the API Playground (password: EluuEz0J).