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.
Set reply_to_row_email when the alert should behave like a direct handoff: the message still arrives at the fixed desk addresses, but pressing Reply addresses the lead recorded in that row. Erdo only accepts the option when the dataset’s verified identity key is an email column; it never guesses from an arbitrary form-answer column. Omit it, or set it to false, to keep replies on the organization’s configured reply route. The default is false. Direct-reply alerts remain one message per lead instead of collapsing a burst into a digest, because a digest has no single lead whose address could safely own Reply-To. GET /v1/dataset-notifications?dataset=<slug> reads the current state back — recipients, reply_to_row_email, 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. Passive capture observations remain available in the lead dataset for investigation. Observe-mode telemetry is withheld from automatic exclusion, alert decisions, peer comparisons and email content. 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.

Buttons

An alert can carry up to three buttons, so the person reading it is one click from the lead in whatever app your team works leads in. Erdo cannot know that app, so you declare the buttons with the notification and Erdo fills them in for each lead:
  • url must be an absolute https URL. {{column}} is replaced by that row column’s value, and {{a|b|c}} by the first of those columns that is not empty. {{lead_ref}} is the lead’s permanent 22-character reference when the dataset is enrolled in lead identity. Each value is URL-encoded as a single component, so nothing a lead types can add a path segment or a query parameter.
  • Placeholders are allowed only in the path, query or fragment. The host a button points at is always the one you declared.
  • requires lists columns that must be non-empty for the button to appear — a Call button needs a phone number even when its URL addresses the lead by id.
  • A button whose placeholders or requires cannot all be filled is left off that email. The alert itself always sends.
  • Buttons appear on single-lead alerts, right under the contact details. A digest stands for many leads and carries none.
Omitting links on a later PUT keeps the saved buttons, so a client that only edits the recipient list cannot remove them; send "links": [] to remove them. GET returns links as an array, empty when none are declared.

Saving without overwriting someone else’s change

Every GET and PUT returns a version, an opaque string that changes each time the notification is saved ("" when none is configured). If you read a notification, change it and write the whole thing back, send the version you read as expected_version:
  • If the notification is still at that version, the save goes ahead and the response carries the new version.
  • If anyone saved it in between, the request is refused with HTTP 409 and nothing is written. Read it again, reapply your change and retry.
  • "expected_version": "" saves only if no notification exists yet.
  • Leave expected_version out to save unconditionally, as before.
Two saves of the same notification never run at the same time. If one is in progress, the other waits a few seconds and, if it is still busy, is refused with HTTP 409 (“another save of this declaration is in progress”) so you can retry.

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

Rows Erdo itself marked as test traffic are never alerted, with or without skip_when: a submission sent with the X-Erdo-Test header or "is_test": true, and Erdo’s own lead-capture health checks. They are left out of digests too, and each run reports how many it passed over (test_skipped). skip_when is for everything else, such as a test a colleague typed in by hand. 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), sms_incoming (someone who texted it). 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 the non-empty skip_when and exclusion.when descriptions are identical, Erdo asks whether that single rule matches and applies the answer to both effects. Different descriptions remain independent policies. 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.