Skip to main content
Speed-to-lead is the whole game: a form submission that nobody hears about for four hours is worth a fraction of one somebody calls back in ten minutes. An arrival notification closes that gap. You name a dataset and a list of addresses, and Erdo emails those people within seconds of a new row arriving. You declare who and where. Erdo owns everything underneath — the automation, its trigger, the watermark that separates new rows from old ones, and the message itself. There is no script to write and nothing to keep in sync.

Why it attaches to a dataset

Notifications hang off the dataset, not off the pipeline that fills it, and that distinction matters more than it first appears. A real development runs many lead-capture pipelines — one per landing page, per language, per variant. One customer has 25 of them, all writing a single leads dataset. A notification attached to a pipeline covers that pipeline alone, so the day someone launches landing page 26, its leads stop reaching anyone and nothing looks broken. Attaching to the dataset covers every writer, including the ones that don’t exist yet.

Setting it up

PUT /v1/dataset-notifications takes the dataset slug and the full recipient list. The list replaces what was there — send the complete set, not a delta.
GET /v1/dataset-notifications?dataset=<slug> reads the current state back — recipients, whether it’s on, and the automation implementing it. That state is read from the automation itself, so there is no second copy of the setting to drift out of sync with reality. Passing an empty recipient list turns email off. It removes the declaration only when exclusion is absent too; with an exclusion rule, a classifier-only notification remains active without a send_email step.
Turning notifications on never delivers a backlog. Where the dataset currently stands is recorded at the moment you configure the notification, so only rows arriving afterwards are announced — including the very first one. Turning notifications off removes the automation rather than pausing it, for the same reason: switching back on months later must not dump everything that accumulated in between into someone’s inbox.

What arrives

Ordinary arrival sends one email per row, so each lead is its own actionable message: who they are and how to reach them first, then what they told the form, then the campaign that brought them. Tracking identifiers — click ids, page URLs, the flags the pipeline stamps on the way in — are join keys for machines and appear nowhere in it. The language they submitted in does appear, because it decides who on the desk can take the call. A burst collapses into a single digest instead of flooding the inbox: either more rows than one run reads, or more messages than one run may send. Every message is tagged with context recording the dataset and which row it concerned — {"kind": "lead_alert", "lead_email": "ana@example.com"} — so your own product can later show “we alerted the team about this lead” on the lead itself. This is the only way that link can be made: the recipient is the sales desk, so nothing about the address says which lead the message was about.

The same lead arriving twice

A lead dataset is not a log. Capture pipelines upsert on the address, so a landing page that asks for an email first and the rest a minute later writes the same lead twice — one row in the dataset, two writes. Announcing that as two leads is wrong, and it is not a rare shape: on one development a quarter of all leads are written more than once, far enough apart that no amount of batching would hide it. The write settles it. Every row records the moment the dataset created it, in a column named erdo_created_at, and a second write to the same row leaves that moment alone however far the arrival column moves. A notification keeps its position in that column as well as in the arrival column, so a row created no later than the point it has already reached is a lead it has announced — including one that arrived in a burst too large for a single run to read. On top of that it also remembers which leads it has announced by address, and what was known about each at the time, which is what covers rows written before erdo_created_at existed. Either way a row it recognises is not announced again, and the run result counts it. Two leads that both arrived without an address are still two leads — an absent address identifies nobody, so neither is suppressed.

Cost

With neither skip_when nor exclusion, nothing per run: the automation is a deterministic script fired by a dataset event, with no polling. Rules that ask Erdo to judge a row use one LLM judgement per new row.
Don’t build this with a recurring agent or a polling heartbeat. A five-minute heartbeat doing this job once cost a customer roughly $81 in a single week to notice one or two leads a day — while also being up to five minutes slower than the event trigger.

Skipping rows you describe

Not every new row deserves an email. Public lead forms attract junk — typo’d addresses that can never receive a reply, gibberish, staff tests — and some desks simply don’t want alerts for rows missing what they need to act. Which rows those are is your call, not the platform’s, so you describe them:
or
Each new row is judged against your description before its email, and a row that matches sends no alert. The judgement is instructed to be conservative: when unsure, the row does not match and the alert is sent. Where the row carries an email address, the judgement also receives a piece of evidence: a DNS lookup of the address’s domain, stated only when it is definitive that the domain cannot receive email — the domain does not exist, it declares that it accepts no mail, every mail server it names is unreachable by design, or it has no mail server and no address at all. The fact informs your criterion rather than overruling it — “spam” will weigh an undeliverable address heavily, while “anyone who did not leave a phone number” will rightly ignore it. Anything short of definitive stays silent, so an ordinary working domain never reads as reassurance. A skipped row is never silently dropped: the automation’s run result records each one with its address, name, and the reason, and the run summary counts them. The row itself is untouched — it still lands in the dataset and still appears in every count; only the alert is suppressed. The check fails open. If the judgement errors, the email sends — a broken skip check must never become a broken alert channel. The gate applies only to per-row alerts; a bulk import’s digest is already one bounded message and is delivered as before. An empty skip_when means every new row is announced. One thing your description can lean on: a leads dataset collects leads from several surfaces, and each lead row names its own in a source column — landing_page_form (a landing page’s lead form), landing_followup_page (the scheduler on a follow-up page), widget_booking_form (a website widget’s “Book a call” form), widget_conversation (details a visitor gave the widget’s assistant in conversation), phone_incoming_call (someone who rang your agent’s number). So “anyone who rang rather than filled in a form” is a rule the judgement can decide from the row itself, and a desk that only wants to hear about typed submissions can say so. Rows captured before the column existed carry a blank source, so say what should happen to those rather than assuming a value. A phone caller usually leaves no email address, only a number. The alert still goes out and names the phone — never announcing a lead is the worse failure — so write your skip_when for what you actually want to hear about. A lead autoreply on the same dataset is the piece that cannot act on such a row: with no address to send to, its send step is recorded as skipped with the reason and the row’s other steps carry on, rather than the row failing on an email that could never have been sent.

Durably excluding rows that are not genuine enquiries

skip_when controls only the interruption. To make a judgement visible in ordinary dataset reads and counts, add a separate durable rule:
Each row receives one structured judgement. automated_submission records automation/bot evidence, while invalid_contact_information separately records placeholder, impossible, reserved, or non-contactable identity. The judgement also carries a reason for each axis. Erdo may use aggregate peer correlation from rows in the same run — shared click identifiers or an exact request/form fingerprint, timing, distinct contacts, and reversed initials — but never puts another row’s raw contact text into the prompt. When exclude is true, Erdo first commits a default dataset filter that keeps every row except that exact source key. The automatic filter has a reserved, deterministic name, so retrying the same row is idempotent while a different row always gets its own filter. Only after that commit does Erdo suppress the alert. The filter’s read-only metadata contains the two judgement axes, their reasons, the rule, key target, display handle, job, and execution provenance. Exclusion fails open. A judgement or filter write error is recorded on the automation run; the row remains visible and, when recipients are configured, the email sends. A live-refresh failure after a successful filter commit is also recorded, but does not undo the committed exclusion. Exclusion mode does not use the digest shortcut: every row is judged and advances the declaration’s ledger exactly once, including erroring judgements. For the design and threat-model details, see the engineering proposal.

Which column marks arrival

Erdo detects the column recording when a row arrived (submitted_at, created_at, received_at, and similar) by probing the dataset’s real columns when you configure the notification. If your dataset names it something unconventional, pass timestamp_column explicitly. Detection happens at configuration time on purpose: a dataset with no usable column is an immediate error you can fix, rather than an automation that looks perfectly healthy and quietly never notifies anyone. Whichever column you name, it has to hold timestamps. The notification starts from the newest value that column already carries and compares later rows against it as text, so a column of free-text answers — a “when are you looking to buy?” question answered “Immediately” — would leave every row that arrives afterwards sorting below it, and nobody would be emailed again. Erdo checks what the column actually holds and refuses the configuration when it is not a timestamp, naming the value it found. An existing notification is checked the same way as it runs, on both the point it is starting from and the one it is about to record. Save it again against a column that records arrival and it recovers by itself on its next run: the unusable starting point is dropped and the notification resumes from where the dataset now stands, with no backlog emailed. Until then its runs fail, naming the column and the value — a run that fails sends nothing and leaves the starting point where it was, so the leads it could not order are still waiting once the column is corrected.

The recipient list is also the permission

Sending email is a consequential action, so an automation cannot do it unless something authorises it — and an unattended run has nobody to ask, which is why a script that emails on a schedule needs a standing approval granted in advance. A notification does not need one, because the recipient list is the authorisation. Saving a notification records permission for that automation to email exactly the addresses you listed. Remove an address and the permission goes with it; remove every recipient and it is withdrawn entirely even when a classifier-only exclusion declaration stays active. The automation cannot reach an inbox you did not name, even if something goes wrong with the message it composes. This is the difference between a notification and a hand-written script that sends mail. The script names its recipients in code nobody reviewed as a decision about who may be written to, so it is refused until somebody grants it. A notification is that decision, made when you saved it.

Limits

At most 10 recipients per dataset — each recipient’s email is one action invocation, and one run may make ten, so a longer list could never be delivered. Sends also count toward your organization’s daily distinct-recipient limit of 50.

Sent email

Read back everything that went out, including these alerts.

Automations

The run history of the automation behind a notification.