A Stripe webhook tells you what it is. The payload has a type field — invoice.paid, customer.subscription.deleted — and you route on it with a switch statement that has worked since the beginning of time. Structured events carry their own routing key, and life is easy.
Then an event shows up that doesn't. A support email, a contact-form submission, the cancel-screen textbox where someone types why they're leaving. No field says what it is or how much it matters — the only thing that knows is the sentence a human typed. Routing on it means reading it, and reading every one by hand is the job you were hoping to avoid.
So classify webhook events with an LLM the moment they arrive: let a model read the thing and write down the label it should have come with.
the field that doesn't exist
Routing by field works right up until the field isn't there. You want when: $event.urgency == "now", but nothing set urgency — the message is a subject line and a paragraph of prose. What you need to route on is unmistakably in there; a furious customer about to churn reads nothing like a thanks-for-the-feature note. It's just not in a column.
So you do one of two bad things. You write keyword heuristics — if (subject.includes("refund")) — that are wrong half the time and rot the moment people phrase things differently. Or you read every message and route by hand, which works at ten a day and falls apart at a hundred.
let a model write the field for you
classify is the step that closes the gap. You hand the event's text to a model along with a typed set of labels, and it hands back the labels — not a paragraph of opinion, the exact values you defined. topic is one of bug, billing, feature_request, sales, spam. urgency is now, soon, or whenever. The field that didn't exist now exists, because something read the event and filled it in.
The typing is the part that makes this safe rather than cute. A free-text model answer would just be another paragraph you'd have to parse. A typed label is a value you can branch on, so when: $classify.urgency == "now" becomes a real condition the same as if Stripe had sent it. You stop routing by which field the event happens to carry and start routing by what the event actually means — which was the only thing that ever mattered.
the call that doesn't fit in a handler
This is where most people stall. A model call takes a few seconds, sometimes more than ten, and a webhook sender won't wait for it — Stripe wants a 200 back inside about twenty seconds or it assumes you failed and retries. So the obvious version is a trap:
// the version that looks reasonable until it isn't
app.post("/webhook/inbound", async (req) => {
const label = await model.classify(req.body.text); // 4s. sometimes 15.
await route(label, req.body);
res.sendStatus(200); // …if we get here in time
});
// stripe gave you ~20s. the model didn't get the memo.Awaiting the model inside the handler welds its thinking time to the sender's patience — two things that have nothing to do with each other. Under a spike, the calls queue, the handler blows past the timeout, the sender retries, and you're classifying the same message three times while the channel fills with dupes.
A pipe breaks the weld. The event is acknowledged the instant it lands — the sender gets its 200 and goes away — and classify runs after, on its own clock, with nothing upstream holding a connection open. That is the whole difference between calling an LLM from your webhook handler, which is fragile, and classifying in the pipe, which is boring.
one inbox, sorted by an llm
Once the label is something you can trust to exist, the routing writes itself — one classify step, then each message to the place that should handle it. Here's the whole pipeline, as the diagram you'd see in the app or the file you commit. Flip between them.
representation
01source
02pipeline · 2 steps
- 01ENRclassifytopic (5) · urgency (3) — typed labels
- 02ENRsummarize$event.body → two lines
03destinations · 4
- toslackSlackchannel#oncallwhen$classify.urgency == "now"
- towebhookWebhookurl$env.BILLING_URLwhen$classify.topic == "billing"
- tonotion.dbNotiontarget_id$env.NOTION_BACKLOG_DBwhen$classify.topic == "feature_request"
- towarehouse.pgPostgrestableevents.inbound
One event in, four destinations out, each gated on what the model read. The #oncall channel only hears about a message when it's genuinely urgent, and gets a two-line summarize instead of the raw thread. Billing questions go straight to billing. A feature request becomes a triaged backlog row. The warehouse keeps every message, sorted or not.
There's no topic or urgency field anywhere in the source event. The model supplied both, once, mid-pipe — and from there it's just data you route like any other.
how ingestlayer does this
Everything above is real. classify and summarize are first-class actions you stack between a source and a destination — you give classify a typed label schema, it returns the labels, and the rest of the pipeline branches on them. You point a webhook or an inbound email at the pipeline, and because the model runs after the event is acknowledged, the call that wouldn't fit in a handler fits here without ceremony.
It's the same shape as Slack pings without the noise, with one difference. There, the moves were filter and dedupe — you had fields to act on. Here the event arrives with no field to route on at all, so the first move is to have a model read it and write one down. The trick was never a smarter switch statement; it was letting something read the event before you have to, then sending the result wherever it belongs. There are other use cases, but an inbox that sorts itself is a satisfying place to start.