Skip to main content

Lead identity

A lead’s contact details can change. canonical_lead_id is the permanent reference for a lead in an enrolled dataset; email and phone are evidence used to find that lead. Lead attributes stay in the dataset row. Turning identity on for an existing dataset is a platform migration. Adding a pipeline option alone does not turn it on or backfill anything.

Lead IDs and conversion IDs

These references mean different things: Do not replace an already-reported conversion transaction ID with the permanent lead UUID. Historical id, lead_id and lid values keep their original meaning. Their presence alone does not establish permanent person identity.

Matching

The platform resolves contact evidence against raw dataset rows, including rows hidden by exclusions. This stops a hidden shared phone number from looking like a unique match.
  • A unique, consistent email or phone match reuses the existing identity.
  • Missing contact information can be filled when it does not conflict with another lead.
  • When a landing-page form submission changes the email or phone of a lead that the same form created a few minutes earlier, the server requires the scoped, short-lived continuation token returned by the accepted capture before treating it as the visitor correcting a typo: the lead keeps its ID, takes the new value, and still recognises the old one. The capture response reports the resolution as corrected.
  • A lead’s stored email and phone change only through such a correction. A later submission carrying an older contact the lead is known by still matches, and leaves the current contact in place.
  • Every later capture adds to the lead rather than replacing it. A new non-blank answer replaces the old one, a blank answer never clears a value, and the lead keeps its first source, campaign attribution and capturing page, so a person who returns through another campaign stays credited to the one that found them.
  • When email and phone point to different leads, or contact evidence is ambiguous, the capture records the unresolved result instead of guessing or creating a third lead.
  • A new eligible contact with no match receives a new permanent ID.
Matching does not remove exclusions or authorize communications. Publicly supplied identifiers are not authorization to read or update another lead. Refused historical submissions can be reviewed and resolved from their original saved evidence, with a separate audited disposition.

Capture responses

An adopted capture pipeline separates the two identities in its response:
leadId remains the conversion reference for compatible existing clients. canonicalLeadId identifies the lead. A client must honor conversionEligible and must not report a conversion merely because capture succeeded. Historical leads without established conversion evidence do not become newly eligible because identity storage changed. Only the operation that creates the acquisition may report it. A later capture for the same lead reuses the conversion reference but is not a new acquisition, so identity resolution alone prevents a repeat submission from double-reporting. The receipt records the platform’s decision; it does not prove an external analytics service received the event. A pipeline can carry the producer contract before its dataset is migrated. It keeps its existing response behavior until the IDs exist, so adoption is safe to apply first. Once IDs are populated, the dataset refuses legacy writes.

A new leads dataset is born with it

A dataset created with purpose: "leads" is given permanent lead identity at creation, while it is still empty. Nothing has to be migrated, and nothing has to be configured: the pipelines built against it afterwards are given the producer contract automatically, so lead capture keys on canonical_lead_id from the very first submission. This applies only to datasets created to hold leads. The purpose is the whole gate — a dataset created for anything else is untouched. Only creation is free. A dataset that already holds leads has rows to backfill and live writers whose contract must change with them, which is a migration, and it stays an operator action — see below.

A dataset that predates it is migrated on its next lead

A leads dataset created before permanent lead identity existed adopts it automatically, the next time a lead lands in it. Nothing to run and nothing to schedule: the write is the moment the platform learns the dataset is in use, and the cheapest moment to migrate, because a dataset taking its first leads has few rows and no automations built on top of it yet. If a consumer blocks the migration — an automation that is running, or one that cannot be re-derived from a platform-owned declaration — the dataset is left exactly as it was and the blocker is recorded. Nothing is half-migrated.

Historical migration

Migration is for a leads dataset that predates the section above, and is how you migrate one on demand rather than waiting for its next lead. ID backfill assigns a distinct permanent ID to each existing source row. It does not merge rows, rewrite historical conversion IDs, or replay capture pipelines. Existing contacts, timestamps and exclusions are preserved. Migration is one operator call per dataset. Start with a preview. It mutates nothing and reports the rows that would be assigned, the pipeline writers that will take the producer contract, and any consumer that blocks the migration. Apply gives every pipeline writer of the dataset the producer contract, then backfills the IDs and publishes the new dataset revision together with the identity metadata in one transaction. No new-row arrival event is emitted. Managed automations need no preparation. canonical_lead_id is reserved for every dataset, generated scripts hide it from their row inputs and content fingerprints whether or not the dataset is migrated, and each script is re-derived from its declaration before it runs. Adding the column therefore cannot change what an automation sees or make on_update: "act" replay rows whose business content did not change. Apply refuses while any consumer is queued, running, or cannot be re-derived from a platform-owned declaration, because the backfill rewrites the file all of them read. A hand-written script is the usual case: it must be adapted or retired first. The refusal names what blocked it.

Running a migration

Dataset and automation edit permissions apply. Preview is the default; apply performs the migration.
A preview reports ready, the blockers holding it up, and how many rows would be assigned an identity. Apply refuses outright while any consumer is incompatible, unverifiable, queued or running, because the backfill rewrites the file every one of them reads — and a disabled automation still blocks, since it can be re-enabled later and would then read a file whose shape had changed. Scoped API keys — the per-end-user credentials — cannot run a migration, for the reason they cannot merge: it rewrites the dataset every other lead lives in. Apply takes a stable operation_key. Retry an uncertain apply with the same key and the committed migration settles it, so a lost response never applies twice. A different key on an already-migrated dataset is refused rather than migrating a second time. Nothing else is carried between calls: everything the migration needs is derived under the dataset write lock at apply time.

Merging two leads

Some people cannot be connected by contact evidence alone, and the platform deliberately refuses to guess:
  • Two leads share an email address or a phone number, so a capture resolves ambiguous.
  • A submission’s email points at one lead and its phone at another, so it resolves conflict.
  • A lead forked before migration, because the old writers matched on email and phone together and one submission had no phone.
  • A phone-only lead returns email-only, with nothing in common.
Merging is how a person settles those. It is an operator action: nothing merges automatically.

What a merge does

The absorbed lead stops being its own lead. In one transaction, under the same per-dataset write lock a capture takes:
  • The absorbed identity points at the survivor.
  • Both leads’ email and phone evidence becomes the survivor’s, so a capture that would have been refused now resolves to the survivor.
  • The two dataset rows become one row. An identity table saying “these are one person” while the dataset still holds two rows is worse than not merging, because every count, export and automation reads the rows.
Rows combine the same way a merge-mode write does. The survivor’s row is the base: a value the absorbed row carries fills a blank in the survivor’s row, and never replaces a value the survivor already holds unless you name that column in overwrite_columns. The combined row keeps the earlier of the two arrival times, because the absorbed row is being destroyed and the later stamp would date the person after they actually arrived.

A merge cannot be undone

The pointer survives, but the absorbed row’s values are combined away. There is no unmerge. Read both leads first. The absorbed identity keeps existing rather than being deleted: capture receipts reference it, and they remain accurate about what happened at the time. An id you already hold — from a capture response or an outbound link — keeps working, and resolves to whatever that lead is now, following a chain of merges to its end. A merge takes no idempotency key. It is identified by the pair of leads it joins, so retrying it after an uncertain response either finds the two already one lead and answers already_merged, or performs the merge that did not commit.

Addressing a lead

A lead is addressed by its canonical_lead_id UUID or by a 22-character lead reference, which is the same value in a form short enough for a link or an SMS. Both forms are accepted everywhere a lead is named, and every response carries both.

Endpoints

Reading one lead also answers where it came from. Each entry in captures carries pipeline_id, the event pipeline that captured that submission — a development runs one pipeline per page, language and variant, and the pipeline’s owner_artifact_id names the page — while the dataset row itself only remembers the latest page. refused_captures lists the submissions the platform would not attribute because this lead was one of the leads it could not choose between. Those name no lead of their own, so they are not in captures, and they are usually the first thing to look at before a merge. lead-candidates also returns lead_identity_enabled. An empty list means two very different things — the platform looked and found nothing, or this dataset has no lead identity at all — so branch on that flag rather than reporting “no duplicates” for a dataset nothing has checked. Reading one lead on such a dataset answers a precondition failure naming the missing migration, not a 404. Ordinary lead reads take dataset view permission and include refusal metadata, without the refused submission or its disposition. The candidate queue, dedicated capture evidence reads, adjudication and merging require dataset edit permission. Applying a refused capture preserves its original form-answer authority. Historical evidence without that saved decision can update the row, but cannot establish lead-authored form answers. Scoped API keys — the per-end-user credentials — cannot merge: one lead’s holder must never be able to fold another lead into it. From the CLI:
Reading a lead is also available to MCP clients as erdo_get_lead and erdo_list_lead_merge_candidates. Merging deliberately is not: it cannot be undone, and the captures it settles are exactly the ones where the evidence contradicts itself, so a person decides.

Linking to a lead

A link addressed to one lead — the button in an autoreply that opens your follow-up page — should carry the lead’s reference, so a visit can be joined back to the lead who was sent it. A dataset row action writes it with {{?lead_ref}}:
{{lead_ref}} is the 22-character reference of the row’s canonical_lead_id. The optional form renders nothing on a dataset that is not enrolled in lead identity yet, so the same declaration works before and after migration. A script step reads the same value as run.lead_ref, because text a script builds is not searched for placeholders afterwards. When the page records the visit, the first-party page-events dataset stores the reference as the UUID it names. So the join from a lead to the pages they read is plain equality, and it also matches links sent before migration, which carried the older lid:
The reference names a lead without describing a person, which is why it may travel in the visible part of a URL and into your analytics. It is not authorization: holding one does not let anybody read or update that lead.