Custom export builder — map an enriched batch into your own CSV columns

Use when the user has an enriched file and needs it in their shape: a dialer import, a CRM template, a client deliverable. Typical asks: "put the best decision-maker phone in number1 and the next one in number2", "match our import spec", "we have 300 columns to fill".

The schema is yours. Nothing about your column names, their order, or their meaning is built into this API — you define them in a profile and the mapper reads that profile at runtime. Never ask the user to conform to a fixed layout.

Tools

What Route Price
List / create profiles GET POST /api/v1/export/profiles free
Derive columns from a pasted sample POST /api/v1/export/profiles/derive free
Save a profile version POST /api/v1/export/profiles/{profile_id}/versions free
Quote a run GET /api/v1/export/runs/estimate free
Preview a few rows POST /api/v1/export/runs/preview paid, per cache-miss row
Poll a preview GET /api/v1/export/runs/preview/{preview_id} free
Start a run POST /api/v1/export/runs paid, per billable row
Poll / list / inspect rows GET /api/v1/export/runs… free
Download the CSV GET /api/v1/export/runs/{run_id}/download.csv free
Cell overrides (fix a value) GET POST /api/v1/export/overrides, DELETE …/{override_id} free

Only the two rows marked paid ever cost money. Everything else — including the estimate, the download, and every correction you make — is free.

Billing order. A paid call debits your virtual USD balance first. Wallet x402 settlement happens only once that balance is exhausted; your wallet is never charged while virtual credit remains. A 402 here carries error: "virtual_balance_insufficient", virtual_balance_usd, bill_up_to_usd, shortfall_usd, and topup_url — show the user the topup_url as a link before you mention on-chain settlement.

Price per row depends on how wide your schema is, not on how much the model thinks: $0.015 per row up to 40 columns, $0.025 above 40, $0.040 above 80. Because it is banded, the estimate is exact rather than a guess.

Workflow

1. Define the destination schema

Paste a sample of the target file and let the API name the columns:

curl -sS -X POST https://411data.io/api/v1/export/profiles/derive \
  -H 'Authorization: Bearer 411_…' -H 'Content-Type: application/json' \
  -d '{"sample": {"CONTACT ID": "44268264", "number1": "", "number2": "", "COMPANY": ""}}'

sample takes a single record, an array of records, or a bare header array ({"headers": ["CONTACT ID", "number1"]}). It returns columns, column_count, and max_columns.

Create the profile, then save a version with the columns plus your mapping instructions:

curl -sS -X POST https://411data.io/api/v1/export/profiles \
  -H 'Authorization: Bearer 411_…' -H 'Content-Type: application/json' \
  -d '{"name": "Five9 dialer import"}'

curl -sS -X POST https://411data.io/api/v1/export/profiles/42/versions \
  -H 'Authorization: Bearer 411_…' -H 'Content-Type: application/json' \
  -d '{
    "columns": [
      {"name": "CONTACT ID", "hint": "pass through the uploaded ID", "source": "source"},
      {"name": "number1", "hint": "phone most likely to reach the decision maker", "source": "leb"},
      {"name": "number2", "hint": "next-closest contact", "source": "leb"}
    ],
    "instructions": "Rank phones by decision-maker proximity. Leave a column empty rather than guessing.",
    "settings": {"hints": ["dm_phone_rank"], "delimiter": ",", "include_header": true}
  }'

hint is free text per column — it is how you say what a column means without the API needing to know. source is leb (enriched graph), source (the uploaded row), or either.

A column may also carry source_ref (for example leb.entities.0.name). That marks it as a stock passthrough: the value is copied from the same flattened lead graph the stock batch CSV uses, the model is never asked about it, and it is excluded from mapping_hash. Adding or removing stock columns bumps the profile version but a re-export of an already-mapped file stays free. Discover available paths with GET /api/v1/export/columns/stock?batch_id=be_… (or the dropdown on /batch/{id}/export).

Enabling the dm_phone_rank hint provider gives the model a deterministically ranked phone list per lead (scored on labels, phone type, contact role, source, and user feedback). It supplies ranked candidates; your instructions and column hints decide which candidate lands in which column.

2. Quote before you spend

curl -sS -G https://411data.io/api/v1/export/runs/estimate \
  -H 'Authorization: Bearer 411_…' \
  --data-urlencode 'batch_id=be_…' --data-urlencode 'profile_id=42'

Read rows_billable and total_usd. rows_cached rows cost $0.00. is_rebuild: true means the entire run is free. If shortfall_usd > 0, show the user topup_url instead of starting the run.

Quote with the same file_instructions and scope you intend to submit — they feed the mapping_hash, so estimating against different text quotes a free rebuild and then charges.

3. Optionally preview a handful of rows

POST /api/v1/export/runs/preview with row_limit 1–5 maps a few rows so the user can sanity-check the instructions before paying for the whole file. Each cell comes back beside the ranked hint: hint_rank tells you where the mapped value sat in the ranking, and hint_rank: null on a filled cell means the model produced something the ranker never offered — worth showing the user.

Preview is not a free sample. It charges cache-miss rows at the run's price, banks each result to the map cache so the later full run does not re-charge those rows, and refunds degraded rows. A preview prepays part of the run.

Preview is asynchronous. Rows map in parallel, but a single row can take over a minute, so the result is not delivered inline — you get 202 with a preview_id and poll it:

curl -sS -X POST https://411data.io/api/v1/export/runs/preview \
  -H 'Authorization: Bearer 411_…' -H 'content-type: application/json' \
  -d '{"batch_id":"be_…","profile_id":14,"row_limit":3}'
# → 202 {"preview_id":"a1b2…","status":"running","rows_total":3,"rows_done":0,…}

curl -sS -H 'Authorization: Bearer 411_…' \
  https://411data.io/api/v1/export/runs/preview/a1b2…
# → {"status":"running","rows_done":1,"previews":[…]}  then status "completed"

Polling is free — the mapping is already paid for. previews carries the rows finished so far in row_index order, so you can render them as they land, and rows_done / rows_total drive a progress display. A preview whose rows are all already cached skips the handle entirely and returns 200 with status: "completed" right away. Previews are kept for one hour after they finish, then the poll returns 404.

4. Run, poll, download

curl -sS -X POST https://411data.io/api/v1/export/runs \
  -H 'Authorization: Bearer 411_…' -H 'Content-Type: application/json' \
  -d '{"batch_id": "be_…", "profile_id": 42, "scope_kind": "all"}'

Poll GET /api/v1/export/runs/{run_id} until status is completed, then download GET /api/v1/export/runs/{run_id}/download.csv. The run precharges the estimate and refunds the unused remainder when it settles or is cancelled.

Scope options: all, row_list (with scope_json: [0, 3, 7] — the way to export a single lead), and rebuild.

Set magic_link_column in the profile's settings to name an extra column, then pass an existing permalink magic link's raw code at download time:

curl -sS -H 'Authorization: Bearer 411_…' \
  'https://411data.io/api/v1/export/runs/42/download.csv?magic_code=…' -o leads.csv

The same URL lands on every row. Omit magic_code and the column stays out of the file entirely (X-411data-Magic-Link-Omitted: 1). Naming the column on the profile is not enough — the raw code is never stored. This costs nothing — output settings are not part of mapping_hash.

Set share_link_column in the profile's settings to name an extra column. At download each row gets its own public /demo/s/{token} snapshot of that lead's enrichment. No code is required, the snapshot does not expire, and an active share for the session is reused so re-downloading does not mint a new URL. This also costs nothing — it is a setting, not a mapping change.

Anyone holding the file can read those leads unauthenticated, so keep the column opt-in and treat the CSV as sensitive. The name must not collide with a mapped column.

The link is a redeemable credential with whatever caps you gave it, and anyone holding the file can redeem it, so the endpoint refuses anything ambiguous: a code that is revoked, expired or unknown; a code belonging to another agent; a single_use link (it would die after the first recipient); and a column name that collides with a mapped column, rather than overwriting your data with a URL. magic_link_path chooses the landing page (/connect, /enrich_agent, /batch).

Phone-shaped cells are rewritten to 10 digits at download (and stored that way on new mappings): +1 (717) 342-2598 and 17173422598 both become 7173422598. A leading = is stripped so a spreadsheet cannot treat the cell as a formula. Notes, addresses, and numbers that are not NANP are left alone. Re-download an existing run to pick this up — it does not rematch.

Re-exporting is free

A mapping decision is paid for once per (lead content × mapping semantics). mapping_hash covers the mapped column set (name / hint / source), the instructions, the hint providers, and the model. It deliberately excludes the profile id, the version number, the column order, CSV output settings, magic_link_* / share_link_column, and every passthrough column (source_ref).

So these are always $0.00:

You are charged again only when the mapping semantics genuinely change — new mapped columns, edited hints or instructions, different hint providers, a different model — or when a lead's enriched data itself changed and you ask for a re-map.

Fixing individual values — free

Do not pay for a re-run to correct a cell. A cell override is free, durable, and applied when the CSV is assembled, so it never mutates the model's output: revoke it and the original value returns.

curl -sS -X POST https://411data.io/api/v1/export/overrides \
  -H 'Authorization: Bearer 411_…' -H 'Content-Type: application/json' \
  -d '{"overrides": [
    {"batch_id": "be_…", "profile_id": 42, "scope": "file",
     "column_name": "number1", "mode": "source_ref",
     "source_ref": "hints.ranked_phones.0.e164"}
  ]}'

Assembly precedence is a contract: row override → file override → mapped value → blank.

Prefer source_ref at file scope. It fixes every row at once and keeps working as rows change. A literal is a human-typed value, so it bypasses the no-invention validator by construction — it is tagged as human-typed and counted on the download rather than blocked, but do not use it to fill in data you do not have.

Re-running only the bad rows

Pass parent_run_id plus filters to re-map just the rows a previous run got wrong. Filters are OR-ed — a union of problems, not an intersection:

Filter Selects
degraded the map produced nothing usable
errored the row failed or was cancelled
guard_flagged a value looked suspect
low_confidence below min_confidence (default 0.7)
column_empty any column you name in empty_columns came back blank
hint_disagreement no top-ranked hint value reached the row
profile_version_lt the parent ran on an older profile version

Any filter requires parent_run_id; without one the API returns 400 PARENT_RUN_REQUIRED, because an empty scope would read as "nothing needs re-running". A filtered re-run always bills the rows it selects — they were chosen precisely because the cached result is wrong. Rows outside the scope carry forward free, so the re-run still downloads as a complete file.

Chaining onto an upload

POST /api/v1/batch/enrich/upload accepts post_enrich_profile_id, post_enrich_instructions, and post_enrich_auto, so an export run fires automatically when enrichment finishes. To bind an already enriched file, PATCH /api/v1/batch/enrich/{batch_id}/post-enrich, then quote it with GET /runs/estimate to show the user the per-lead price and the file total before anything runs.

Anti-patterns