Event pipelines
An event pipeline is a deterministic flow that receives an inbound event and does fixed work with it: a lead-form submission, a third-party webhook, or an in-app action arrives at the pipeline’s endpoint, gets normalized, and runs through an ordered list of actions — write the row to a dataset, send an email, call a connected integration, broadcast a live update, or return a custom response. Pipelines are how a page captures leads, and how Erdo enriches and routes them without an agent in the loop on every event. Each pipeline has:- a slug and name — a stable, human-readable handle (e.g.
acme-leads-a1b2c3d4), - a state —
active,disabled, orproposed, - a source kind —
browser_form,third_party_webhook,custom_http,internal, …, - an inbound endpoint URL — where events are POSTed,
- an auth mode — how inbound events are verified (HMAC, shared secret, provider signature, signed-in viewer, or public),
- the actions it runs and the datasets it writes to, and
- an execution log — every event it received, with status (
accepted/rejected/failed), a payload preview, the response, and any error.
Where data lands
A pipeline’sdataset.write actions persist rows to a dataset — per-row, SQL-queryable
storage. To see captured leads, query the dataset the pipeline writes to (shown as
write_target_datasets), not the pipeline itself. The pipeline’s execution log tells you
whether events are arriving and being accepted; the dataset holds the rows.
A failed storage write stops the execution before later actions or a success
response, even if that write sets continue_on_error. Optional side effects
can still continue on error after capture succeeds. Marked test and health-probe
submissions remain stored for verification; their JSON object responses explicitly
set conversionEligible: false so they cannot authorize an advertising conversion.
Datasets enrolled in permanent lead identity use a canonical
capture contract to resolve contacts and write the lead atomically. The permanent
lead reference is separate from the conversion transaction ID. Enrollment,
producer adoption and backfill happen together in one operator call; an ordinary
pipeline edit does not migrate historical rows.
Preparing dataset columns before the first event
When you create or update an enabled pipeline, providesample_payload with a
representative event body. The transform runs without executing actions or
writing a customer row. Its output is checked against existing target contracts.
For a durable dataset with no stored file and no declared schema, that output
initializes the columns before the first real event. Platform purpose columns
and any enrolled identity key are included automatically.
Include every optional output column in the sample. Dynamic output shapes need
an explicit dataset schema. Existing files and contracts are preserved; samples
cannot widen them. For an unwritten purpose-bound target that has no complete
contract, an enabled create, re-enable, or producer change requires a sample that
produces normalized
rows, or a complete schema declared beforehand. A legacy metadata-only edit can
continue but returns a setup warning. Cloning an enabled source with such an
incomplete target is refused until the source is configured. Disabled pipelines
and unbound datasets can retain deferred schema discovery. The sample must also
satisfy request_schema. A revision becomes visible only after its target
contracts are ready; a refused registration leaves the previous revision active.
Concurrent edits abort instead of overwriting a newer revision. Widget and
conversation lead producers establish their fixed output contracts through the
same registration before becoming active. An existing built-in also establishes
a missing contract on lookup even when its configuration is already current,
without changing its pipeline revision.
Assigning a partial or custom purpose to an unwritten durable dataset requires a
complete declared producer schema. You can declare the schema and set its purpose
in one schema update; the declaration is composed with the requested purpose and
settled before that purpose is published. Clearing a purpose with a wholesale
declaration does not re-add columns belonging only to the previous purpose.
Emails a pipeline sends
A pipeline that emails on an event — a lead alert to your sales inbox, a confirmation back to the person who submitted the form — has Erdo compose the message. The pipeline supplies the content (a heading, the captured fields as label/value rows, a button through to the page the lead came from) and Erdo renders it in the standard Erdo styling, with a plain-text version alongside for clients that block HTML. Addresses and links in the fields become clickable, so a lead alert can be replied to from the notification itself.Pipeline purpose
Every pipeline declares apurpose — its role — so you can tell capture flows apart from
analytics infrastructure without guessing from names:
record_capture— a pipeline whose job is persisting submitted records to a dataset (it has at least onedataset.writeaction). This is how you find which datasets receive a flow’s records: filter pipelines bypurpose = record_captureand read theirwrite_target_datasets(each entry is a fully-qualifiedorg-slug.dataset-slug). You can set this explicitly when creating or updating a pipeline; acustompipeline that writes to a dataset is also promoted torecord_captureautomatically at save time.page_events— the managed first-party page-analytics pipeline described below. Reserved for Erdo’s own provisioning; you can’t set it.custom— everything else: a webhook or enrichment flow that doesn’t persist records.
record_capture pipelines and
read their write_target_datasets, then query that dataset.
Every dataset.write row is stamped at ingestion with columns your transform doesn’t set:
variant, artifact_id, page_url, and optional session_key (from the page envelope), plus request signals that
let you keep your metrics clean and segment them by who actually showed up:
session_key— the opaque browser-session key shared by page events and form captures. Join it with the page and time window to follow a visit through to its saved lead. The shared page runtime supplies it automatically; custom page forms usingerdo.submitEventneed no extra field or transform mapping. It is internal correlation metadata, never authentication or a person’s identity. Canonical lead merges retain the first recorded session; a later visit does not replace it. For an older lead gaining a key on a later capture, that key does not identify its original acquisition visit. Session storage preserves the key within the tab when available; blocked storage limits continuity to the current document. Missing or invalid optional keys do not reject submissions. Historical captures without a key remain unknown.is_test— a boolean, always present.truewhen the submission was tagged as test traffic (see Marking test traffic below), otherwisefalse. Filter it out to count real leads and conversions.device_type—"mobile","tablet","desktop", or empty, classified from the request’sUser-Agentat ingestion. Segment funnels by it to compare mobile vs desktop (a page can take most of your spend yet convert zero mobile visitors — this column surfaces that without parsing User-Agents by hand).browser— the coarse browser family ("chrome","safari","firefox","edge","samsung","opera","other", or empty), also from theUser-Agent.in_app— the in-app webview container the visit came from ("instagram","facebook","tiktok", and similar), or empty for a normal standalone browser. In-app webviews routinely break modals and payment flows, so this separates “converts badly on mobile” from “converts badly inside Instagram’s browser”.language— the visitor’s preferred language, the primary tag of theAccept-Languageheader (e.g."pt-BR"), or empty. Check that a Brazilian, Colombian, or French campaign is actually reaching PT/ES/FR speakers.country,region,city— the visitor’s coarse location: ISO-2 country (e.g."US"), ISO-3166-2 region code (e.g."NY"), and city name, from the serving edge. Empty when the request reaches the pipeline through an ingress that carries no edge geo. Segment by them to verify geo-targeted campaigns (e.g. “New Yorkers relocating to Miami”) landed where you paid for.utm_source,utm_medium,utm_campaign,utm_term,utm_content,utm_id,gclid,gbraid,wbraid,fbclid,li_fat_id— the campaign the visitor arrived from, read from the query string of the page they submitted on. This is what makes a per-campaign cost-per-lead a plainGROUP BY utm_campaignover your leads dataset, and what lets a lead join to the pageview that produced it — page events carry the identically-named columns.gclidis the Google click id, needed to report the conversion back to Google Ads;gbraidandwbraidare what Google sends instead on iOS traffic with app tracking restricted. A column stays empty when the visit carried no such parameter, which is what direct and organic traffic looks like.
page_url, so you can read the attribution back out of it —
regexp_extract(page_url, 'utm_campaign=([^&#]*)', 1).
utm_id and li_fat_id carry LinkedIn attribution: LinkedIn’s URL tracking appends utm_id
beside the other UTMs, and LinkedIn adds li_fat_id to ad clicks. utm_id is kept exactly as
the URL supplied it, as text (leading zeroes survive), and is never interpreted as a particular
campaign or ad id. On a lead that is captured more than once, each campaign column keeps its
first non-empty value, so a later visit can fill a missing id but never replaces one already
recorded. Rows captured before these columns existed are not backfilled; their page_url still
holds the raw values.
Platform columns and declared schemas
For file datasets with a declared schema, capture writes add the platform columns present in that batch as optional columns. This includes request metadata, campaign attribution, widget analytics dimensions and conversation lead fields. Your existing column types, required fields and qualification rules are preserved. Other unexpected transform columns still appear as schema drift; captured rows are retained. Datasets without a declared schema keep their existing behavior. This automatic extension applies to file datasets; external database tables retain their existing schema rules.Passive observations and optional form protection
Browser-form captures retain passive observations for investigations without changing submission acceptance or assigning a bot score. For selected public forms, you can separately enable Managed Turnstile protection. It is off by default per pipeline and does not rank leads by IP address. Protected captures recordturnstile_status as verified or unavailable when
verification succeeds or the server observes a verification outage. Missing or
invalid tokens require verification before capture. These statuses are set by the
server; a form or transform cannot supply a trusted verification result.
Marking test traffic
QA checks and automated evaluations submit to the same pipelines your real visitors do, so their rows would otherwise inflate lead and funnel counts. Tag a submission as test traffic and it lands withis_test = true, keeping it out of your real-traffic metrics. Two equivalent markers:
- Send the request header
X-Erdo-Test: 1(1ortrue), or - Include a top-level
"is_test": truefield in the JSON body.
is_test = true rows, so their funnel counts exclude test traffic automatically;
add the same is_test not_equals true default filter to your own lead datasets to get the same
behaviour on reads.
Auto-provisioned page-events pipelines
Alongside the pipelines you or your agents create, every published page gets one page-events pipeline automatically (itspurpose is page_events — see
Pipeline purpose above). It receives the page’s first-party analytics events — pageviews, scroll
milestones, section views, CTA clicks, form starts, leads, video plays — and writes them to your
organization’s shared Page events dataset, stamped with the page, experiment variant, session,
campaign attribution, is_test, device_type, browser, in_app, language, and geo
(country/region/city). Real-user LCP, INP, and CLS measurements arrive as web_vital rows
with vital_name, vital_value, and vital_rating. The server also distils recorded sessions into
scroll_bail, rage_click, and form_abandon rows with redaction-safe typed dimensions; these
names cannot be submitted through the public browser endpoint. Events outside that fixed vocabulary
are rejected, which you
can see in the pipeline’s execution log. Disabling a page-events pipeline turns first-party
analytics off for that page; Erdo won’t re-create it. See
Pages → First-party page events for what’s captured and how to
query it.
The generated page_events action and its shared Page events dataset target are reserved:
updates may change ordinary metadata or disable the pipeline, but cannot remove, duplicate, or
redirect that write. Trusted replay receipts fail closed unless exactly one keyed write succeeds
against the canonical dataset, so a response-only, zero-write, wrong-target, or multiple-write
configuration cannot be recorded as settled.
Managing your pipelines
You can list your pipelines, read one’s configuration, review its run history, create new pipelines, update their editable fields, and rotate their verification secrets over MCP, the REST API, and the CLI. Everything is org-scoped — a caller only ever sees their own organization’s pipelines — and pipelines are referenced by slug (a UUID is also accepted).CLI
erdo_update_event_pipeline accept the same
object in the optional top-level turnstile field. Omission preserves the current
policy; an enabled update may omit secret to preserve its encrypted value.
The CLI authenticates with your Erdo token and the active organization (erdo login, erdo org).
MCP tools
Identity and RBAC ride the request context, so a caller can only reach their own org’s pipelines.
Signed-in pages can submit to active
authenticated_viewer browser-form pipelines with the
record_capture purpose that belong to that page. The page viewer can discover eligible IDs
without seeing pipeline configuration, targets, auth settings or secrets; full configuration
still requires edit access. The server checks the current pipeline settings again when a
workspace submission arrives. Workspace analytics remain suppressed.
REST
Base URLhttps://api.erdo.ai. Authenticate with Authorization: Bearer <token> and select the
org with X-Organization-ID.
To confirm a pipeline is capturing events end-to-end, check its executions here, then query the
dataset it writes to.
Marking failures handled
After investigating and dealing with failures, record what you did so health reports can exclude those executions from unresolved failure counts:--execution <id> instead. When IDs
are supplied, they take precedence over the cutoff. The command returns
{"marked": 12} with the number newly handled.
The REST body accepts note, an optional RFC3339 through timestamp, and an
optional execution_ids array. MCP exposes the same inputs with pipeline_slug
through erdo_mark_event_pipeline_failures_handled. Notes are required, trimmed,
and limited to 500 characters; at most 1000 execution IDs are allowed, and future
cutoffs are refused.
The executions list includes handled_at, handled_by, and handled_note on
handled failures. The original outcome and error remain in the history. Accepted
executions are never marked, and marking an already-handled execution preserves
its first audit entry. Handling a failure does not retry it. Identity refusals
with failure_stage: "lead_resolution" are never marked, by cutoff or by ID:
they are submissions waiting for a decision, settled through
lead-capture dispositions.
Reading an execution’s failure_stage
Each execution carries a failure_stage naming the step its outcome was decided at —
transform, lead_resolution, dataset_write, write_target, auth, request_validation,
state_gate, verification, execute or schema_contract. Read it together with status
rather than matching the text of error, which is prose and changes.
Creating a pipeline
POST /v1/event-pipelines (or erdo_create_event_pipeline) takes:
name(required) — the slug is derived from it,state—active,disabled, orproposed(the default); passactiveto start capturing immediately,source_kind—browser_form,third_party_webhook,custom_http(default), …,auth_modeandauth_config— how inbound events are verified.hmac_sha256(the default),shared_secret_header, andprovider_signaturecarry a verification secret: omit it and one is generated for you.authenticated_vieweraccepts signed-in page viewers — setauth_config.audienceto"public_and_viewers"for a public lead form,request_schema— a JSON Schema the inbound payload must satisfy,transform_js—function transform(event) { … }normalizing the payload before the actions run,pipeline— the ordered actions array, e.g.[{"type": "dataset.write", "dataset_slug": "riverview-leads"}]. Reference datasets by slug, never UUID — a plain slug targets your own organization’s datasets, and a well-formed slug that doesn’t exist yet is auto-created as the pipeline’s write target (reported back increated_datasets), so provisioning a new campaign’s lead capture is one call,purpose—customorrecord_capture(see Pipeline purpose);page_eventsis reserved,owner_artifact_id— the page this pipeline belongs to, for page-fed pipelines.
PUT /v1/event-pipelines/:slug.
Save-time warnings
The create and update responses carry awarnings array — advisory findings the save does not
refuse but you should act on. Empty means no findings.
The one warning today catches a split leads dataset: a pipeline that captures leads by email
upsert while other active lead-capture pipelines in your organization write to different
datasets. That shape usually comes from two build sessions each minting their own leads dataset
(a slug collision suffix like -6894 is the tell), and it silently strands leads — every page
looks healthy, but dashboards and email dedupe see only the fraction landing in their dataset.
When you see it, point every lead-capture pipeline for one customer at a single leads dataset
(an update repointing the dataset.write step), or disable the stale pipelines.
Secrets are shown once
For the secret-bearing auth modes, the create and rotate responses carry the plaintext verification secret in asecret field — exactly once. List and get never show it again
(the stored auth_config is redacted on every read), so store it when you receive it. Rotating
(POST /v1/event-pipelines/:slug/rotate-secret) mints a fresh secret and invalidates the old one
immediately — update the sender’s signing configuration right away. Pipelines whose auth mode
carries no secret (authenticated_viewer, public_artifact, none) reject rotation.
