ingestlayer/blog

all posts
Post#playbook

Get a Slack ping for every signup, without the noise.

Wiring a webhook to Slack so it pings on every signup takes five minutes. Making those pings worth reading is the actual job. Here's the pipe in between.

ben7 min read


Wiring a webhook to Slack so it pings on every signup takes about five minutes. You fire an event, a line shows up in a channel, and you can watch your product happen in real time. It is a genuinely useful setup, and most teams build some version of it.

It also stops being useful after about a week — not technically, but practically. Every signup, every page view, every retried Stripe webhook, and every bot poking your form lands in the same channel with the same weight. Signal and noise look identical, so the channel becomes something you scroll past and eventually mute.

the version everybody ships first

It almost always starts like this — a handler, a JSON blob, a channel.

webhook.ts
// the version everybody ships first
app.post("/webhook/stripe", async (req) => {
  await slack.post("#alerts", JSON.stringify(req.body));
});
// → fires on every event. forever. at 3am too.

This is fine right until it isn't. The problem isn't the webhook, and it isn't Slack. The problem is that you took a firehose and aimed it at a room where humans are supposed to read things. Every event is treated as equally interesting, which is the same as saying none of them are.

the channel you learned to ignore

The failure modes are boring and universal. Stripe sends you the same event twice, so you get two pings for one payment. A scripted signup spike at 3am fills the channel with forty identical lines. The one enterprise email you actually wanted to see is buried between nineteen free-plan throwaways and a bot. None of these are Slack's fault, and none of them get better by staring harder.

Someone on Hacker News asked the exact question — how do you handle internal event webhooks without the channel turning into noise you learn to ignore — and the thread is mostly people quietly admitting they muted their own alerts. It is a rite of passage. It is also avoidable.

the fix is a pipe, not a Slack filter

The instinct is to fix it inside Slack: keyword filters, a second channel, a do-not-disturb schedule. That treats the symptom. The real fix is to do the work before the message is ever sent. Between the event arriving and the ping going out there is room for a few cheap, boring operations that turn a firehose back into a signal. Four of them do most of the work.

filter drops anything you would never act on. A free-plan signup from a disposable address is real, but it is not a thing you are going to do anything about at 3am. filter takes a predicate and quietly discards everything that doesn't match, before it ever costs you attention.

dedupe handles the fact that the same event will arrive more than once. Stripe is famous for it; most providers do it eventually. dedupe keys on whatever makes the event unique — an email, an id — and collapses repeats inside a window, so one payment is one ping.

throttle is for when everything is real and there is just a lot of it. A launch, a press hit, a bot that found your form. throttle caps how many pass per key per minute, so a spike becomes a steady trickle instead of a wall you scroll past.

classify is the interesting one. Instead of writing brittle rules about what counts as important, you hand the event to a model with a typed set of labels and let it decide. classify can read a signup and tell you hot_lead, normal, or spam — and now your Slack rule is one line: only ping when it's hot.

what that looks like as one file

Put the four moves together and the noisy signups channel becomes this.

pipelines/signups.yaml
# pipelines/signups.yaml
pipeline: signups

source:
  type: sdk.event
  match: user.signup

steps:
  - filter:                      # drop the ones you won't act on
      when: $event.plan != "free"

  - dedupe:                      # the same signup can arrive twice
      key: $event.email
      within: 1h

  - enrich.person: $event.email  # who is behind the address
  - classify:                    # let a model read it before you do
      input: $event + $person
      labels:
        intent: [hot_lead, normal, spam]

  - throttle:                    # survive a signup spike
      key: signups
      max: 20
      per: 1m

destinations:
  - slack:
      channel: "#signups"
      when: $classify.intent == "hot_lead"
      template: |
        {{ $person.name }} — {{ $person.company }}
        plan {{ $event.plan }} · reads as a hot lead
  - warehouse.pg:
      table: events.signups      # everything still lands here

Slack only hears about hot leads. The warehouse still gets every signup, because Postgres never gets tired of reading. One event comes in, two destinations go out, each shaped for who is on the other end — the human channel gets the cream, the table gets the firehose. And because the pipeline is a file you commit, the rule that decides what is worth your attention diffs in a pull request like any other code.

how ingestlayer does this

This is, more or less, the whole reason ingestlayer exists. The four moves above aren't pseudocode — filter, dedupe, throttle, and classify are real actions you stack between a source and a destination. You point a webhook or the SDK at it, compose the steps your event family needs, and route the result wherever it belongs.

And because a pipeline is just YAML, the rule that decides what reaches you lives in one readable file, not scattered across a dashboard. Signups are the example everyone hits first, but the same shape works for payments, support emails, or GitHub events — anything that arrives with more volume than attention. There are plenty of other use cases, but the noise problem is the same one every time.


Read next

Introducing ingestlayer.

I kept rebuilding the same pattern: an event happens here, something has to happen there. So I built the universal solution I wanted.

← back to all posts