---
title: "Durable notification workflows, in your own code."
url: "https://palumb.com"
description: "Notification workflows in your own codebase, rendered by your engine, delivered by your provider. Operated in the EU under EU law alone."
---

-   [Made in EU](/about/)
-   [Open source Soon](/open-source/)

# Durable notification workflows, in your own code.

Palumb is notification infrastructure that calls your code: unlimited workflows, your own sending provider, and the whole delivery path open source.

[Join the beta](/waitlist/) [See how it works](#cases)

The beta is free, open to everyone, and it does not end.

Pick a notification problem to see the workflow that solves it 

Orders Invoices Onboarding Trials Digests Comments

How do you follow an order to feedback?

How do you follow an order all the way to feedback?

Three groups, one under the other, and a single vertical line running down outside them on the left that marks the flow of data. Nothing travels along that line. Instead the stretch between two ends fills, from whoever is calling toward whoever is called, holds while that one works, and then drains back the way it came. The fill is always the same grey, the ink of the Palumb mark: the line belongs to Palumb, and which end the fill starts from already says who placed the call. It carries a small stop at every height where something happens.

First group, yourapp.com/backend: your own application, and the calls it makes into Palumb: trigger for orderPlaced on topic.order-24 and sendEvent for order.shipped. Neither the bubble nor its mark exists until you make that call, and the mark is a small teal square that fills in once Palumb has it. While a call is in flight the bubble's border turns and the line below it fills down to Palumb.

Second group, palumb, the durable engine: the one that is not an address, because it is not something you run. It appears when the trigger arrives, and holds one bubble that says running workflow, except while it is waiting: then it names what it waits for, waiting order.shipped and delay, with the elapsed count in front where the wait has a limit. At the end it says run complete. Its border turns for as long as the run lives, through every wait. Just under it the journal grows, one small violet token per step that has closed, each carrying that step's number.

From the second group the fill runs down to the exact row that is about to run, and stops on it. It is not the channel of the call: your workflow is one signed endpoint, and the body replays from the top with the completed steps rehydrated until it reaches the row it has not run yet. The line is the flow of control, and control really does end up on that row.

Third group, yourapp.com/workflows/orderPlaced: your workflow file itself, 14 lines of TypeScript, with its line numbers. In the margin of every line that calls a step there is a numbered circle, the way an editor marks a breakpoint: 1 for step.send, 2 for step.waitForEvent, 3 for step.send, 4 for step.delay, 5 for step.send. At rest the margin holds only the line numbers; a step's number appears when that step starts running.

When the fill reaches a row, a band lights it, the way a debugger marks where it has stopped, and the ring in its margin turns. The moment the step is recorded the number leaves the margin, a tick takes its place, and the number reappears as a token in the row under Palumb: you wrote the step, we are the ones who remember it ran.

Not every wait is ended by an event: some are not, and those run out their clock instead. While that happens the line down to your code is grey and empty and no row is lit, while the run stays alive in Palumb. That is the frame that matters: for as long as the wait lasts your server does nothing at all and ours does.

While the run waits, the Palumb mark leaves its column and a small clock takes its place, its hand turning once for every unit of time that goes by, and the bubble puts the elapsed count in front of what it is waiting for: day 6, waiting order.shipped. The hand is fast on purpose: it is there to say that the time is moving, not to be counted.

Each wait has its own limit, taken from the code: 30 days for step.waitForEvent, cut short by the event on day 6 and 3 days for step.delay, run out in full. A wait that runs out counts up to its limit; one an event closes stops where the event arrived, and that is the number the bubble ends on.

The drawing follows one path through the code. An if has two ways out and the animation takes the one this page is about; the other ways are in the file, which is printed in full below.

At rest, which is also what anyone who has asked for less motion sees, every step the run reached is ticked, the journal holds its 5 tokens, the bubble says run complete, and the line is empty: the drawing is the finished picture of this path.

order-placed.ts

```ts
workflow("orderPlaced", async ({ payload, step }) => {
  await step.send("confirmation", "email", () => confirmation(payload))

  const shipped = await step.waitForEvent("shipped", {
    event: "order.shipped",
    timeout: "30d",
  })
  if (!shipped) return

  await step.send("tracking", "email", () => tracking(shipped))

  await step.delay("cool-off", { seconds: 3 * 24 * 3600 })
  return step.send("feedback", "email", () => feedback(payload))
})
```

[Read it with the code →](/use-cases/order-updates/)

How do you chase only the unpaid invoice?

How do you chase an unpaid invoice without chasing someone who paid?

Three groups, one under the other, and a single vertical line running down outside them on the left that marks the flow of data. Nothing travels along that line. Instead the stretch between two ends fills, from whoever is calling toward whoever is called, holds while that one works, and then drains back the way it came. The fill is always the same grey, the ink of the Palumb mark: the line belongs to Palumb, and which end the fill starts from already says who placed the call. It carries a small stop at every height where something happens.

First group, yourapp.com/backend: your own application, and the call it makes into Palumb: trigger for invoiceDue on topic.invoice-77. Neither the bubble nor its mark exists until you make that call, and the mark is a small teal square that fills in once Palumb has it. While a call is in flight the bubble's border turns and the line below it fills down to Palumb.

Second group, palumb, the durable engine: the one that is not an address, because it is not something you run. It appears when the trigger arrives, and holds one bubble that says running workflow, except while it is waiting: then it names what it waits for, waiting invoice.paid, with the elapsed count in front where the wait has a limit. At the end it says run complete. Its border turns for as long as the run lives, through every wait. Just under it the journal grows, one small violet token per step that has closed, each carrying that step's number.

From the second group the fill runs down to the exact row that is about to run, and stops on it. It is not the channel of the call: your workflow is one signed endpoint, and the body replays from the top with the completed steps rehydrated until it reaches the row it has not run yet. The line is the flow of control, and control really does end up on that row.

Third group, yourapp.com/workflows/invoiceDue: your workflow file itself, 15 lines of TypeScript, with its line numbers. In the margin of every line that calls a step there is a numbered circle, the way an editor marks a breakpoint: 1 for step.send, 2 for step.waitForEvent, 3 for step.send. At rest the margin holds only the line numbers; a step's number appears when that step starts running.

When the fill reaches a row, a band lights it, the way a debugger marks where it has stopped, and the ring in its margin turns. The moment the step is recorded the number leaves the margin, a tick takes its place, and the number reappears as a token in the row under Palumb: you wrote the step, we are the ones who remember it ran.

Not every wait is ended by an event: none of them is, and those run out their clock instead. While that happens the line down to your code is grey and empty and no row is lit, while the run stays alive in Palumb. That is the frame that matters: for as long as the wait lasts your server does nothing at all and ours does.

While the run waits, the Palumb mark leaves its column and a small clock takes its place, its hand turning once for every unit of time that goes by, and the bubble puts the elapsed count in front of what it is waiting for: day 6, waiting order.shipped. The hand is fast on purpose: it is there to say that the time is moving, not to be counted.

Each wait has its own limit, taken from the code: 7 days for step.waitForEvent, run out in full. A wait that runs out counts up to its limit; one an event closes stops where the event arrived, and that is the number the bubble ends on.

The drawing follows one path through the code. An if has two ways out and the animation takes the one this page is about; the other ways are in the file, which is printed in full below.

At rest, which is also what anyone who has asked for less motion sees, every step the run reached is ticked, the journal holds its 3 tokens, the bubble says run complete, and the line is empty: the drawing is the finished picture of this path.

invoice-due.ts

```ts
import { workflow } from "@palumb-com/sdk"
import { reminder, finalNotice } from "../emails"

workflow("invoiceDue", async ({ payload, step }) => {
  await step.send("reminder", "email", () => reminder(payload))

  // durable: a deploy in the middle does not lose it
  const paid = await step.waitForEvent("paid", {
    event: "invoice.paid",
    timeout: "7d",
  })
  if (paid) return

  return step.send("final", "email", () => finalNotice(payload), { to: payload.accountOwner })
})
```

[Read it with the code →](/use-cases/unpaid-invoice/)

How do you nudge a user who never returns?

How do you nudge a user who signed up and never came back?

Three groups, one under the other, and a single vertical line running down outside them on the left that marks the flow of data. Nothing travels along that line. Instead the stretch between two ends fills, from whoever is calling toward whoever is called, holds while that one works, and then drains back the way it came. The fill is always the same grey, the ink of the Palumb mark: the line belongs to Palumb, and which end the fill starts from already says who placed the call. It carries a small stop at every height where something happens.

First group, yourapp.com/backend: your own application, and the calls it makes into Palumb: trigger for activation on topic.user-91 and sendEvent for user.activated. Neither the bubble nor its mark exists until you make that call, and the mark is a small teal square that fills in once Palumb has it. While a call is in flight the bubble's border turns and the line below it fills down to Palumb.

Second group, palumb, the durable engine: the one that is not an address, because it is not something you run. It appears when the trigger arrives, and holds one bubble that says running workflow, except while it is waiting: then it names what it waits for, waiting user.activated, waiting user.activated and waiting user.activated, with the elapsed count in front where the wait has a limit. At the end it says run complete. Its border turns for as long as the run lives, through every wait. Just under it the journal grows, one small violet token per step that has closed, each carrying that step's number.

From the second group the fill runs down to the exact row that is about to run, and stops on it. It is not the channel of the call: your workflow is one signed endpoint, and the body replays from the top with the completed steps rehydrated until it reaches the row it has not run yet. The line is the flow of control, and control really does end up on that row.

Third group, yourapp.com/workflows/activation: your workflow file itself, 23 lines of TypeScript, with its line numbers. In the margin of every line that calls a step there is a numbered circle, the way an editor marks a breakpoint: 1 for step.waitForEvent, 2 for step.send, 3 for step.waitForEvent, 4 for step.send, 5 for step.waitForEvent, 6 for step.send. At rest the margin holds only the line numbers; a step's number appears when that step starts running.

When the fill reaches a row, a band lights it, the way a debugger marks where it has stopped, and the ring in its margin turns. The moment the step is recorded the number leaves the margin, a tick takes its place, and the number reappears as a token in the row under Palumb: you wrote the step, we are the ones who remember it ran.

Not every wait is ended by an event: some are not, and those run out their clock instead. While that happens the line down to your code is grey and empty and no row is lit, while the run stays alive in Palumb. That is the frame that matters: for as long as the wait lasts your server does nothing at all and ours does.

While the run waits, the Palumb mark leaves its column and a small clock takes its place, its hand turning once for every unit of time that goes by, and the bubble puts the elapsed count in front of what it is waiting for: day 6, waiting order.shipped. The hand is fast on purpose: it is there to say that the time is moving, not to be counted.

Each wait has its own limit, taken from the code: 24 hours for step.waitForEvent, run out in full, 24 hours for step.waitForEvent, run out in full and 5 days for step.waitForEvent, cut short by the event on day 2. A wait that runs out counts up to its limit; one an event closes stops where the event arrived, and that is the number the bubble ends on.

The drawing follows one path through the code. An if has two ways out and the animation takes the one this page is about; the other ways are in the file, which is printed in full below. On this path the run ends before the file does: step.send is never reached, so no mark appears in the margin of that line at all, only the line number.

At rest, which is also what anyone who has asked for less motion sees, every step the run reached is ticked, the journal holds its 5 tokens, the bubble says run complete, and the line is empty: the drawing is the finished picture of this path.

activation.ts

```ts
workflow("activation", async ({ payload, step }) => {
  const day1 = await step.waitForEvent("wait-1", {
    event: "user.activated",
    timeout: "24h",
  })
  if (day1) return
  await step.send("nudge-1", "email", () => gettingStarted(payload))

  const day2 = await step.waitForEvent("wait-2", {
    event: "user.activated",
    timeout: "24h",
  })
  if (day2) return
  await step.send("nudge-2", "email", () => whatOthersBuild(payload))

  // five more days — seven since sign-up
  const week = await step.waitForEvent("wait-3", {
    event: "user.activated",
    timeout: "5d",
  })
  if (week) return
  return step.send("nudge-3", "email", () => lastCall(payload))
})
```

[Read it with the code →](/use-cases/activation-reminders/)

How do you warn, then stop on upgrade?

How do you warn before a trial ends, and stop the moment they upgrade?

Three groups, one under the other, and a single vertical line running down outside them on the left that marks the flow of data. Nothing travels along that line. Instead the stretch between two ends fills, from whoever is calling toward whoever is called, holds while that one works, and then drains back the way it came. The fill is always the same grey, the ink of the Palumb mark: the line belongs to Palumb, and which end the fill starts from already says who placed the call. It carries a small stop at every height where something happens.

First group, yourapp.com/backend: your own application, and the calls it makes into Palumb: trigger for trialEnding on topic.user-58 and sendEvent for subscription.upgraded. Neither the bubble nor its mark exists until you make that call, and the mark is a small teal square that fills in once Palumb has it. While a call is in flight the bubble's border turns and the line below it fills down to Palumb.

Second group, palumb, the durable engine: the one that is not an address, because it is not something you run. It appears when the trigger arrives, and holds one bubble that says running workflow, except while it is waiting: then it names what it waits for, waiting subscription.upgraded, waiting subscription.upgraded and waiting subscription.upgraded, with the elapsed count in front where the wait has a limit. At the end it says run complete. Its border turns for as long as the run lives, through every wait. Just under it the journal grows, one small violet token per step that has closed, each carrying that step's number.

From the second group the fill runs down to the exact row that is about to run, and stops on it. It is not the channel of the call: your workflow is one signed endpoint, and the body replays from the top with the completed steps rehydrated until it reaches the row it has not run yet. The line is the flow of control, and control really does end up on that row.

Third group, yourapp.com/workflows/trialEnding: your workflow file itself, 23 lines of TypeScript, with its line numbers. In the margin of every line that calls a step there is a numbered circle, the way an editor marks a breakpoint: 1 for step.waitForEvent, 2 for step.send, 3 for step.waitForEvent, 4 for step.send, 5 for step.waitForEvent, 6 for step.send. At rest the margin holds only the line numbers; a step's number appears when that step starts running.

When the fill reaches a row, a band lights it, the way a debugger marks where it has stopped, and the ring in its margin turns. The moment the step is recorded the number leaves the margin, a tick takes its place, and the number reappears as a token in the row under Palumb: you wrote the step, we are the ones who remember it ran.

Not every wait is ended by an event: some are not, and those run out their clock instead. While that happens the line down to your code is grey and empty and no row is lit, while the run stays alive in Palumb. That is the frame that matters: for as long as the wait lasts your server does nothing at all and ours does.

While the run waits, the Palumb mark leaves its column and a small clock takes its place, its hand turning once for every unit of time that goes by, and the bubble puts the elapsed count in front of what it is waiting for: day 6, waiting order.shipped. The hand is fast on purpose: it is there to say that the time is moving, not to be counted.

Each wait has its own limit, taken from the code: 11 days for step.waitForEvent, run out in full and 2 days for step.waitForEvent, cut short by the event on day 1. A wait that runs out counts up to its limit; one an event closes stops where the event arrived, and that is the number the bubble ends on.

The drawing follows one path through the code. An if has two ways out and the animation takes the one this page is about; the other ways are in the file, which is printed in full below. On this path the run ends before the file does: step.send, step.waitForEvent and step.send are never reached, so no mark appears in the margin of those lines at all, only the line number.

At rest, which is also what anyone who has asked for less motion sees, every step the run reached is ticked, the journal holds its 3 tokens, the bubble says run complete, and the line is empty: the drawing is the finished picture of this path.

trial-ending.ts

```ts
workflow("trialEnding", async ({ payload, step }) => {
  const early = await step.waitForEvent("quiet", {
    event: "subscription.upgraded",
    timeout: "11d",
  })
  if (early) return

  await step.send("three-days", "email", () => threeDaysLeft(payload))
  const late = await step.waitForEvent("warned", {
    event: "subscription.upgraded",
    timeout: "2d",
  })
  if (late) return

  await step.send("tomorrow", "email", () => endsTomorrow(payload))
  const last = await step.waitForEvent("final", {
    event: "subscription.upgraded",
    timeout: "1d",
  })
  if (last) return

  return step.send("ended", "email", () => trialEnded(payload))
})
```

[Read it with the code →](/use-cases/trial-ending/)

How do you send a summary every Monday?

How do you send a weekly summary on a fixed schedule?

Three groups, one under the other, and a single vertical line running down outside them on the left that marks the flow of data. Nothing travels along that line. Instead the stretch between two ends fills, from whoever is calling toward whoever is called, holds while that one works, and then drains back the way it came. The fill is always the same grey, the ink of the Palumb mark: the line belongs to Palumb, and which end the fill starts from already says who placed the call. It carries a small stop at every height where something happens.

First group, yourapp.com/backend: your own application, and the calls it makes into Palumb: trigger for weeklyDeadlines on topic.user-91, sendEvent for task.due · Tue, sendEvent for task.due · Wed and sendEvent for task.due · Fri. Neither the bubble nor its mark exists until you make that call, and the mark is a small teal square that fills in once Palumb has it. While a call is in flight the bubble's border turns and the line below it fills down to Palumb.

Second group, palumb, the durable engine: the one that is not an address, because it is not something you run. It appears when the trigger arrives, and holds one bubble that says running workflow, except while it is waiting: then it names what it waits for, collecting task.due, with the elapsed count in front where the wait has a limit. At the end it says run complete. Its border turns for as long as the run lives, through every wait. Just under it the journal grows, one small violet token per step that has closed, each carrying that step's number.

One of those waits is a collection, and it is what this drawing is about: step week gathers 3 events before its window closes. While a window is open the bubble names the event it is gathering and counts what it already holds, and the count goes up at each arrival. Every arrival lights the bubble teal for a moment and starts nothing of yours: the line down to your code stays grey and empty. The step returns the whole lot at once, when the clock says so and not when the last event lands.

From the second group the fill runs down to the exact row that is about to run, and stops on it. It is not the channel of the call: your workflow is one signed endpoint, and the body replays from the top with the completed steps rehydrated until it reaches the row it has not run yet. The line is the flow of control, and control really does end up on that row.

Third group, yourapp.com/workflows/weeklyDeadlines: your workflow file itself, 12 lines of TypeScript, with its line numbers. In the margin of every line that calls a step there is a numbered circle, the way an editor marks a breakpoint: 1 for step.collect, 2 for step.send, 3 for step.trigger. At rest the margin holds only the line numbers; a step's number appears when that step starts running.

When the fill reaches a row, a band lights it, the way a debugger marks where it has stopped, and the ring in its margin turns. The moment the step is recorded the number leaves the margin, a tick takes its place, and the number reappears as a token in the row under Palumb: you wrote the step, we are the ones who remember it ran.

While the run waits the line down to your code is grey and empty and no row is lit, while the run stays alive in Palumb. That is the frame that matters: for as long as the wait lasts your server does nothing at all and ours does.

The drawing follows one path through the code. An if has two ways out and the animation takes the one this page is about; the other ways are in the file, which is printed in full below.

At rest, which is also what anyone who has asked for less motion sees, every step the run reached is ticked, the journal holds its 3 tokens, the bubble says run complete, and the line is empty: the drawing is the finished picture of this path.

weekly-deadlines.ts

```ts
workflow("weeklyDeadlines", async ({ subscriber, step }) => {
  const items = await step.collect("week", {
    event: "task.due",
    until: nextMonday("09:00", "Europe/Rome"),
  })

  if (items.length) {
    await step.send("digest", "email", () => deadlines(items))
  }

  return step.trigger("next", "weeklyDeadlines", subscriber.id)
})
```

[Read it with the code →](/use-cases/weekly-summary/)

How do you stop one email per comment?

How do you stop sending one email per comment?

Three groups, one under the other, and a single vertical line running down outside them on the left that marks the flow of data. Nothing travels along that line. Instead the stretch between two ends fills, from whoever is calling toward whoever is called, holds while that one works, and then drains back the way it came. The fill is always the same grey, the ink of the Palumb mark: the line belongs to Palumb, and which end the fill starts from already says who placed the call. It carries a small stop at every height where something happens.

First group, yourapp.com/backend: your own application, and the calls it makes into Palumb: trigger for commentBurst on topic.doc-12, sendEvent for comment.added · 1, sendEvent for comment.added · 2 and sendEvent for comment.added · 3. Neither the bubble nor its mark exists until you make that call, and the mark is a small teal square that fills in once Palumb has it. While a call is in flight the bubble's border turns and the line below it fills down to Palumb.

Second group, palumb, the durable engine: the one that is not an address, because it is not something you run. It appears when the trigger arrives, and holds one bubble that says running workflow, except while it is waiting: then it names what it waits for, waiting comment.added and collecting comment.added, with the elapsed count in front where the wait has a limit. At the end it says run complete. Its border turns for as long as the run lives, through every wait. Just under it the journal grows, one small violet token per step that has closed, each carrying that step's number.

One of those waits is a collection, and it is what this drawing is about: step window gathers 2 events before its window closes. While a window is open the bubble names the event it is gathering and counts what it already holds, and the count goes up at each arrival. Every arrival lights the bubble teal for a moment and starts nothing of yours: the line down to your code stays grey and empty. The step returns the whole lot at once, when the clock says so and not when the last event lands.

From the second group the fill runs down to the exact row that is about to run, and stops on it. It is not the channel of the call: your workflow is one signed endpoint, and the body replays from the top with the completed steps rehydrated until it reaches the row it has not run yet. The line is the flow of control, and control really does end up on that row.

Third group, yourapp.com/workflows/commentBurst: your workflow file itself, 14 lines of TypeScript, with its line numbers. In the margin of every line that calls a step there is a numbered circle, the way an editor marks a breakpoint: 1 for step.waitForEvent, 2 for step.collect, 3 for step.send, 4 for step.trigger. At rest the margin holds only the line numbers; a step's number appears when that step starts running.

When the fill reaches a row, a band lights it, the way a debugger marks where it has stopped, and the ring in its margin turns. The moment the step is recorded the number leaves the margin, a tick takes its place, and the number reappears as a token in the row under Palumb: you wrote the step, we are the ones who remember it ran.

While the run waits the line down to your code is grey and empty and no row is lit, while the run stays alive in Palumb. That is the frame that matters: for as long as the wait lasts your server does nothing at all and ours does.

The file has no branch in it, so the one path the drawing follows is the only path there is. It is printed in full below.

At rest, which is also what anyone who has asked for less motion sees, every step the run reached is ticked, the journal holds its 4 tokens, the bubble says run complete, and the line is empty: the drawing is the finished picture of this path.

comment-burst.ts

```ts
workflow("commentBurst", async ({ payload, subscriber, step }) => {
  const first = await step.waitForEvent("first", {
    event: "comment.added",
  })

  const rest = await step.collect("window", {
    event: "comment.added",
    until: minutesFrom(first.receivedAt, 5),
  })

  await step.send("burst", "email", () => comments([first, ...rest]))

  return step.trigger("next", "commentBurst", subscriber.id, payload)
})
```

[Read it with the code →](/use-cases/comment-bursts/)

## No vendor lock-in.

Everything that would be hard to leave behind is already yours. In your repository, on your provider, under a licence you can run yourself.

[See what is open](/open-source/)

What stays yours

### Your workflows

notifications/order-placed.ts

In your git repository. Versioned, reviewed, diffable. We keep no copy: we call your deployment and deliver what it returns.

### Your templates

invoice.tsx

Whatever engine you already use. There is no Palumb template system, so there is nothing to migrate off.

### Your providers

SMTP\_HOST=…

Your server, your API keys, your sending reputation. Palumb has no sending capacity of its own.

### The engine itself

AGPL-3.0

The whole delivery path is published. Self-host it and you owe us nothing.

What you take on

### Running it yourself

The machines, the upgrades, the backups, the monitoring, and whatever breaks at three in the morning.

## The hard parts, handled.

The parts of a notification system that are easy to promise and slow to get right: waits that survive a deploy, retries that do not hammer the provider, a record of what actually happened to every attempt. Palumb ships them so you do not have to write them yourself.

[All features](/features/) [Use cases](/use-cases/)

A workflow branching into the three ways time enters it, all three of them steps in its body: gathering events until Monday, a durable delay of three days, and waiting for a shipped signal.

### Gathering, waiting, or sleeping for days.

A run can gather what arrives **until an instant your code names**, stay alive through **hours or days** without losing its place across a crash or a deploy, or pause until **your app sends a signal** or the timeout fires.

### Any sending provider.

Send through the **channel a workflow needs** (**Email, SMS, Telegram, Slack or push**), each on your own sending provider, never pooled with anyone else's.

### Fan-out.

**Subscribe a recipient to a topic** once; every **trigger** on that topic reaches everyone still on it, each with their own run. No loop you write yourself.

### However you reach it.

Open the **dashboard** to look, call the **REST API** to build on it, or point your own **AI agent** at the MCP server. Same data, three ways in.

### Tried, tracked, never twice.

A failed send is retried **on a schedule that backs off**, a retry never duplicates one that already went out (it carries the same **Message-ID**), and **every attempt** ends up in the record: accepted, failed or bounced, with the reason.

Three events reach one POST /sendEvent: comment.added, task.assigned, deadline.soon. It collects them into a single digest document with three entries and one email.

### Digests.

**Each event becomes one item** in a digest that builds up over a window; when it closes, every item goes out in **one notification**, not one message per event.

Four template syntaxes stacked: a Handlebars tag, a React Email component, an MJML tag, and a plain template string.

### Any template engine.

**Keep the template engine you already use**: MJML, React Email, Handlebars, or a plain string. Palumb never renders; it only delivers what you hand it.

## What do you write it in?

The SDK is a library you import in TypeScript, PHP, .NET, Python, Go, Java or Rust, and it ships on the deploy you already run: no new runtime, no extra service, nothing to orchestrate.

### Self-hosted: three services on your machine.

A standard, lightweight stack: the application, Restate and PostgreSQL, on Node.js. The whole delivery path is under AGPL-3.0.

[Self-hosting](/self-hosting/)

docker-compose.yml

```yaml
services:
  app:
    image: palumb/palumb
  restate:
    image: restatedev/restate
  postgres:
    image: postgres:17
```

three services, one file

### Fully managed: the same three, on ours.

We run the managed cloud version of Palumb: backups, upgrades, monitoring and patches are ours to handle, on European infrastructure. Your environment is still infrastructure as code: a Terraform file you version instead of a console you click. And because it runs the exact same software you would self-host, switching between the two is a deploy, not a migration.

[Pricing](/pricing/)

What you declare converges into one Terraform file: providers, api keys, environment. The file reaches Palumb, and from there backups, upgrades, monitoring stop being yours.

## European ownership, not just EU hosting.

Every company in the delivery path is European. So is the one you would take to court.

No parent in another jurisdiction to compel, no hyperscaler in the middle. You can check the lot, and who owns them, in [the sub-processor list](/sub-processors/); who can compel whom, and what adequacy actually settles, is on [the jurisdiction page](/eu-jurisdiction/).

A map of Europe with seven marked countries: Italy for founder, France for cloud, data, Germany for domain, mail, Netherlands for monitoring, Slovenia for CDN, Sweden for contact form, Estonia for payments. Every one of them is inside the European Union.

## Who is Palumb for?

Engineers who would rather keep the notification logic in their own repository, on the stack they already have, than hand it to someone else's engine. Europe and regulated sectors are where the question gets asked first, not the only place it gets asked.

[See the use cases](/use-cases/)

### Engineers, not editors

You would rather review a pull request than a canvas.

### Operational sending

Your notifications are transactional, and getting one wrong costs you.

### A European stack, on purpose

You are building in Europe, and you would rather your suppliers were too.

### [Migrating off a vendor](/compare/)

You already run a notification platform, and want the exact differences before you touch it.

Palumb is bootstrapped: no investors, no board, no exit to answer to. The European argument on this page is worth only what the company behind it is free to keep, and there is no one else with a claim on that. [Who builds Palumb →](/about/)

Everyone who joins gets in, and it costs nothing. Your invite goes out when the beta opens.

[Join the beta](/waitlist/)