> ## Documentation Index
> Fetch the complete documentation index at: https://docs.erdo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Lead sources: sheets, webhooks and imports

> Bring leads Erdo did not capture into your lead dataset, from a Google Sheet, a tool that posts each lead, or a CSV file.

Some leads reach you somewhere other than an Erdo page: a Meta lead form whose
answers land in a Google Sheet, a portal your agency forwards through Zapier, a
list from an event. A lead source brings them into your lead dataset, each
exactly once, so they get the same lead page, the same follow-up and the same
reports as every other lead.

There are three kinds:

| Kind | How leads arrive | Use it for |
| - | - | - |
| `google_sheets` | Erdo reads the sheet every 5 minutes | A sheet you already keep, whatever fills it |
| `webhook` | Your tool posts each lead to Erdo as it arrives | Zapier, Make, LeadsBridge, a CRM |
| `import` | You send a CSV file with `erdo provider-lead-sources import` | One-off lists and backfills |

The dataset must have [lead identity](https://docs.erdo.ai/lead-identity.md) turned on, so a person
who reaches you two ways stays one lead. Lead sources are behind a feature flag
that is off by default. For LinkedIn lead forms read straight from LinkedIn,
see [LinkedIn lead form sync](https://docs.erdo.ai/provider-lead-sources.md).

## The mapping

Every sheet, webhook and import source carries a mapping: which field is
which lead column. Only the fields you map are read, so a sheet's own notes
(a status column your team fills in, who called whom) never reach the lead.

```json theme={null}
{
  "lead_source": "meta_lead_form",
  "format": "meta_lead_export",
  "default_country": "US",
  "columns": {
    "are_you_a_broker?": "is_broker",
    "how_did_you_hear_about_us?": "heard_about_us"
  }
}
```

* `what_people_did` is your own sentence for what someone did before the
  source brought them in, such as "Filled in the contact form on our partner
  portal". The follow-up reads it to know how the person got in touch, and
  each lead the source creates keeps it (`import_source_description`). A
  webhook always needs it; a sheet or an import needs it or `lead_source`.
* `lead_source` is what the person did as one of Erdo's values:
  `meta_lead_form`, `linkedin_lead_gen_form` or `imported_list`. It is
  optional. The `meta_lead_export` format sets `meta_lead_form` itself.
* `format` is `columns` (the mapping alone) or `meta_lead_export`, for Meta's
  own lead export. The Meta format reads who the person is (id,
  created\_time, name, email, phone, company, job title and location) and
  removes the `l:` and `p:` prefixes Meta adds. Meta's ad, ad set, campaign,
  form and placement fields are read only when `columns` names them, under
  their `ad_*` names (`campaign_name` as `ad_campaign_name`, never as a
  campaign tag); the preview lists them as not read, with those names
  suggested.
* `row_id` is the field, or fields, that identify one lead in the source. A row
  is written once per id. The Meta format uses `id`.
* `submitted_at` is the field that says when the person got in touch. The Meta
  format uses `created_time`.
* `default_country` is the country of a phone written without a country code,
  for a lead whose own country (`location_country`, as an ISO code such as
  `US`) is not known. A lead's own country wins.
* `values` maps a column's answers to the values the column holds, by column
  then answer as the source writes it: `{"is_broker": {"Yes, I'm an agent":
  "true", "No": "false"}}`. The lookup ignores case and surrounding spaces. An
  answer the map does not name is stored as written, and a sheet keeps an
  alert open naming it until the source is saved with it mapped.
* `consent` is how a sheet's or import's leads agreed to texts:
  `{"method": "checkbox"}` for a box they ticked, `{"method": "disclosure"}`
  for a notice beside the submit button, optionally with the wording as
  `text`. When the mapping reads a consent column into `consent_given`, each
  yes is recorded with this method, and a no or a blank stays as it is. When it
  reads none, every lead is recorded as having agreed this way. Without
  `consent`, nothing is recorded where a row has no answer, and the lead is not
  texted. A webhook posts consent per lead.
* `ignore` lists fields the format reads by itself that this source should
  not read, such as Meta's `company_name`. Meta's `id` cannot be ignored, and
  `email` and `phone_number` cannot both be. A dry run lists every field it
  reads, with its column, as `read_columns`.
* `keep_other_fields` (webhooks only) keeps every field your tool posts that
  `columns` does not name, under the field's own name made into a column name
  ("Budget range?" becomes `budget_range`). A field that would replace a column
  Erdo writes itself is skipped, and at most 50 are kept per lead. A sheet or a
  file never keeps unnamed fields, because they often carry the sales team's
  own notes.
* `columns` maps any other field to a lead column name. A source that asked the
  person for consent to calls and texts can map it to `consent_given` (`true`
  or `false`; `yes` and `no` are read too), `consent_method` (`checkbox` for a
  ticked opt-in box, `disclosure` for consent by submitting) and
  `consent_text` (the sentence they agreed to), the way a landing page records
  it. Only a ticked box replaces an answer already on the lead.

## Suggested columns and values

A sheet's dry run matches each field it does not read yet to one of your lead
columns: the ones you list in `suggest_columns` (each with a short
description), or, without that list, the columns your leads dataset already
has. `are_you_a_broker?` reads as `is_broker`, "What's your budget?" as
`budget`. A field that matches none of them gets no suggestion: it is left out
rather than given a new column. For a matched field holding a small fixed set
of answers, the dry run also suggests the value each answer is stored as
(`suggested_values`).

The suggestions come from a model that sees the field names, the columns and
their descriptions, and the answers only of fields whose few answers repeat,
so free text such as names, emails or notes is never sent. Nothing is read
until you include it in `columns` and `values` and save.

## Which import a lead came from

Every lead a sheet, webhook or import brings in is marked as imported, with
the source's id, kind and name (`import_source_id`, `import_source_kind`,
`import_source_name`) and its `what_people_did` (`import_source_description`), so you
can see which import it came from. Only the import that
creates the lead marks it: a lead you already captured yourself stays yours
when a sheet or import later carries the same person. LinkedIn lead forms are
read from your own connected LinkedIn account and are not marked as imported.

## Leads that got in touch earlier

When a sheet source is first saved, its existing rows are people who got in
touch before Erdo knew about them. They, and every imported row, are written as
**history**: dated by when they got in touch, never sent as a new-lead alert,
never counted as new, and not followed up until somebody starts them from the
lead page or with `--start-follow-up`. Rows that arrive after the source is
saved are new leads like any other.

## A Google Sheet

Open the sheet to anyone with the link (**Share > General access > Anyone with
the link, Viewer**), then copy its address from the browser with the right tab
open. The `#gid=` at the end names the tab.

```bash theme={null}
erdo provider-lead-sources set --file brickell-sheet.json --dry-run
```

```json theme={null}
{
  "dataset": "brickell.leads",
  "provider": "google_sheets",
  "name": "brickell-meta-forms-spreadsheet",
  "sheet_url": "https://docs.google.com/spreadsheets/d/1bF_…/edit#gid=0",
  "mapping": { "lead_source": "meta_lead_form", "format": "meta_lead_export", "default_country": "US" }
}
```

`--dry-run` reads the sheet and shows what saving would write: how many rows
are history and how many new, which fields are not read (with a suggested
column for each), rows that would be skipped and why, and the first rows as
leads. Nothing is saved. Run it again without `--dry-run` to save.

If the sheet stops being open to anyone with the link, Erdo cannot read it.
The source's `last_error` says so, and after three failed reads it appears in
your attention feed. A sheet that reads fine but stops getting new rows for
three days, after usually getting them, also appears there: whatever fills it
may have stopped.

A row Erdo cannot read (no id, or a date it cannot understand) is named in
your attention feed by its row number. Fix it in the sheet and the next read
writes it; the item resolves after a read with none left.

## A webhook

```bash theme={null}
erdo provider-lead-sources set --file zapier.json
```

```json theme={null}
{
  "dataset": "brickell.leads",
  "provider": "webhook",
  "name": "agency-zapier",
  "mapping": {
    "what_people_did": "Filled in a Meta lead form the agency's Zapier forwards",
    "lead_source": "meta_lead_form",
    "row_id": ["lead_id"],
    "default_country": "US",
    "columns": { "email": "email", "phone": "phone", "full_name": "name" }
  }
}
```

The answer includes `webhook_url` and `webhook_secret`. **The secret is shown
once.** Your tool posts each lead to `webhook_url` as one JSON object, with the
secret in the `X-Erdo-Lead-Source-Secret` header:

```bash theme={null}
curl -X POST "$WEBHOOK_URL" \
  -H "X-Erdo-Lead-Source-Secret: $SECRET" \
  -H "Content-Type: application/json" \
  -d '{"lead_id":"z-1","email":"sam@example.com","phone":"3055550161","full_name":"Sam"}'
```

| Answer | Meaning |
| - | - |
| `200 {"status":"created"}` | A new lead |
| `200 {"status":"existing"}` | Matched a lead you already have |
| `200 {"status":"already_received"}` | This `row_id` was posted before; nothing written |
| `202 {"status":"held_for_review"}` | The contact details point at more than one lead |
| `400` | The body is missing a mapped field; the error names it |
| `401` | Wrong address or secret |
| `422` | Neither an email nor a phone |

Preview a mapping with `"dry_run": true` and a `"sample"` body. Rotate the
secret with `--rotate-secret`; the old one stops working at once. A webhook that
usually delivers and goes three days without a new lead appears in your
attention feed: the tool sending it may have stopped.

## An import

Declare an import source once, then send files to it:

```bash theme={null}
erdo provider-lead-sources set --file event-list.json
erdo provider-lead-sources import event-signups list.csv --default-submitted-at 2026-09-12
erdo provider-lead-sources import event-signups list.csv --default-submitted-at 2026-09-12 --commit
```

Without `--commit` the import is a preview. `--default-submitted-at` dates rows
the file does not date, including rows whose date cell is blank; a date Erdo
cannot read is skipped, never replaced. Importing the same file again writes
nothing new. `--start-follow-up` (with `--commit`) also starts follow-up on each
lead the file's rows created, including ones an earlier attempt created, so an
import that stopped part-way can simply be run again. Export Excel files as CSV.

The REST equivalent is `POST /v1/provider-lead-sources/import` with `name`,
`header` and up to 1,000 `rows`.

## From an agent over MCP

The same sources can be declared from Claude or any MCP client connected to
Erdo. Each tool takes the same fields as the REST body above.

| Tool | What it does |
| - | - |
| `erdo_list_lead_sources` | Lists sources with their dataset, mapping, state, last read, last error and `updated_at`. Never shows a webhook secret. |
| `erdo_save_lead_source` | Declares or replaces a source. `dry_run` is required: `true` returns the preview, `false` saves. |
| `erdo_import_lead_source_rows` | Sends one batch of a file (`header` and up to 1,000 `rows`) to an import source. `dry_run` is required. |
| `erdo_delete_lead_source` | Removes a source by `name`, or a LinkedIn source by `provider` and `account_id`. Takes `if_updated_at`. |
| `erdo_list_lead_source_forms` | Lists a LinkedIn ad account's lead forms, for a [LinkedIn source](https://docs.erdo.ai/provider-lead-sources.md). |

The dataset is named by its slug and a sheet, webhook or import source by its
name. `mapping` is the same object as above; a key Erdo does not know is
refused, so a typo cannot quietly map nothing. A new webhook, or one saved with
`rotate_secret`, returns `webhook_secret` once in the tool's answer, as it
does over REST. The tools never start follow-up on imported leads: the import
returns `created_leads`, and a person starts them from the lead page or with
`--start-follow-up`.

## Saving without overwriting

Saving a source again under its name replaces it. Two fields stop that from
overwriting somebody else's work:

* `"create_only": true` (`--create-only` on the CLI) refuses the save if a
  source with that name already exists.
* `"if_updated_at"` set to the source's `updated_at`, as you read it, refuses
  the save if anyone has saved the source since. Read it again and redo your
  change.

A save that does not include `enabled` keeps the source's current state, so
rotating a paused webhook's secret leaves it paused.

## Listing and removing

```bash theme={null}
erdo provider-lead-sources list
erdo provider-lead-sources delete --name brickell-meta-forms-spreadsheet \
  --if-updated-at 2026-09-12T14:30:00.123456Z
```

Use the source's `updated_at` from `list` with `--if-updated-at` when removing
it. The delete is refused if somebody edited or replaced that source after the
list, so read it again before retrying. The REST equivalent is the optional
`if_updated_at` query parameter.

Removing a source stops it reading. Leads already written stay.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.