DEV Community

Cover image for Stop building notifications per product — notify, one platform for email, Slack, LINE, and webhooks
uehara
uehara

Posted on

Stop building notifications per product — notify, one platform for email, Slack, LINE, and webhooks

The short version

  • What we built: a shared platform called notify that sends notifications to email, Slack, LINE, and webhooks (automated system-to-system notifications) through a single API. Our internal products no longer implement their own notification plumbing; they just use the SDK (Software Development Kit — the client library for calling the platform) and a per-project API key.
  • Why we built it: multiple products were each implementing email sending and Slack notifications separately. Similar code multiplied with every product, and LINE notifications never got implemented in any product at all.
  • The key point: the hard part of notifications is not "being able to send" — it is "not sending twice" and "not silently failing to send." The mechanism that prevents duplicate sends, and the mechanism that automatically falls back to the next channel on failure, live in the platform, not in each product. Today 13 projects send their notifications through it.

The full story (about a 14-minute read)

As you grow the number of products inside a company, you notice there are features that every product needs. Login, billing, and notifications. We consolidated login into an authentication platform (ELN ID) and billing into a billing platform. This article introduces the third item in that lineup: the notification platform notify.

The notify product page. A shared notification platform for email, Slack, LINE and webhooks (actual page at www.eln.ne.jp/products/notify)

Every product had notifications, and every product built them from scratch

It started when I lined up the notification code across our products. The support product, the monitoring product, and the corporate site each had their own code: Amazon SES (Simple Email Service, AWS's service for sending and receiving email) for email, and webhooks for posting to Slack. Written by different people at different times, so the details behaved differently everywhere.

In other words, similar code grew with every email feature we shipped. And LINE notifications — which everyone agreed would be useful — existed nowhere, because every product kept postponing them. Building LINE integration for a single product is never worth it. For all products at once, the math changes. So we decided to treat notifications not as a feature of each product, but as a product of its own.

At the end of June 2026 we extracted the email-sending code from the corporate site as a library. At that point it was email-only. Two weeks later, on July 16, we wrote the architecture decision record: notify would be a real product with its own screens and API, not a library. The next day we built the four-channel support, the send orchestrator, the admin console, and the SDK in one push. Since then the platform has kept growing on demand from its consumers: signatures and template management in August, ten-language authentication emails in September, multi-channel Slack support in September.

What notify does

Seen from a consuming product, notify looks like this.

  • One API, four channels. Email, Slack, LINE, and webhooks are called the same way, through a single endpoint (POST /api/v1/send). Callers stop hand-rolling "how do we deliver this" for every feature.
  • Destinations have priorities with automatic fallback. The platform tries the channels in the order you list them and stops falling back once one succeeds. If the first channel is down, the notification itself does not disappear.
  • No duplicate sends. A request can carry a send ID (a marker that ensures a request received twice is only sent once). A resend with the same ID is safely ignored, so callers can retry after failures without fear.
  • Three ways to address recipients. Direct addresses; user references, which resolve only to verified bindings (double opt-in — unverified destinations are skipped, not sent); and registered Slack channels referenced by name.
  • Email templates and signatures live in the platform. Six default transactional templates (organization invitations, inquiry receipts, and so on) plus per-project custom templates. Signatures compose automatically as body → project signature → company signature, so products stop pasting signatures into every message.
  • Authentication emails ship in ten languages. English, Japanese, Simplified Chinese, Korean, Spanish, Portuguese, French, German, Arabic, and Hindi — selected by the user's locale.
  • Anti-spam is built in. Bounces and complaints automatically enter a suppression list; emails carry one-click unsubscribe headers (List-Unsubscribe). Not annoying recipients is enforced by the platform, not by each product's discipline.
  • Everything is visible in an admin console. Projects, API key issuance and revocation, channel configuration, send logs, and test sends — with login through our own identity platform (ELN ID).

notify's main features: multi-channel delivery, priority and fallback, duplicate-send prevention, anti-spam, and the admin console (actual page at www.eln.ne.jp/products/notify)

The calling code is this small:

const client = createNotifyClient({ baseUrl, apiKey });
await client.send({
  targets: [{ userRef: 'user-123' }, { address: 'ops@example.com' }],
  channels: ['email', 'slack'],
  topic: 'billing',
  message: { subject: "Invoice finalized", text: "This month's invoice is ready." },
});
Enter fullscreen mode Exit fullscreen mode

The SDK keeps its runtime dependencies to a single validation library. Transient failures (5xx, network errors) are retried up to three attempts total with exponential backoff starting at 200 ms; caller mistakes (4xx) fail immediately without retries. If you omit the send ID, the SDK generates one — so "forgot the ID, sent twice" cannot happen either.

Consolidating notifications into one platform — Before (per-product implementations, LINE nowhere) and After (products use just the SDK and an API key; notify owns send IDs and fallback) (diagram drawn from the implemented architecture)

The design decision I care most about — business outcomes are not exceptions

Notifications have results that are neither success nor communication error: "skipped because the same send ID was already processed" (duplicate) and "not sent because the recipient opted out" (rejected). These are not anomalies. They are the platform doing its job correctly.

notify returns them not as HTTP error statuses (4xx) but in the body of a normal 200 response.

POST /api/v1/send
200 { status: "processed" }   // sent
200 { status: "duplicate" }   // skipped: same send ID (normal)
200 { status: "rejected" }    // not sent: suppressed recipient (normal)
Enter fullscreen mode Exit fullscreen mode

Why? If you return business outcomes as 4xx, most HTTP clients collapse them into a single "request failed" exception, and callers can no longer tell "we prevented a duplicate" from "we actually failed." Worst of all, on retries, duplicate throws as a fake failure. So exceptions are reserved for transport and authentication errors only; business branches live in the body. We wrote this reasoning into the design document so that nobody "fixes" it back to 4xx in good faith later. The idea transfers to API design far beyond notifications.

Reasons come back as a fixed vocabulary, not free text: topic_opt_out, binding_not_verified, channel_config_missing, slack_channel_not_registered. Callers can branch on these, and whoever reads the logs doesn't have to guess.

Our cost-monitoring product shows why this matters in practice: it treats a duplicate response as "delivered, but not fresh this time," tracking "did it arrive" separately from "did we newly send." That distinction is only possible because the outcome survives in the response body.

Inside the send ID — what it takes to make "retry safely" true

"Same ID, send once" sounds simple. A naive implementation does not actually deliver it. Three things had to be built:

  • Reservations are first-come-first-served. The (project, send ID) pair is claimed with a conditional database write; only the first claim wins, and later ones return duplicate without entering the send path.
  • If sending crashes mid-flight, the reservation is rolled back. Otherwise a reservation with no send behind it remains forever, and the same ID becomes permanently duplicate — the safety mechanism mutates into a mechanism that permanently blocks resends.
  • The ID is released only when every destination failed transiently. If everything failed with 5xx or network errors, a later retry has a real chance, so the key opens up again. If even one destination succeeded, was suppressed, or failed permanently, the key stays consumed — retrying would not change the outcome.

To make that decision computable, we also unified the vocabulary of failure across channels: HTTP 5xx and network errors are retryable, Slack's logical API errors are not, LINE's rate limit (429) is. Each channel implementation reports this one flag, and the orchestrator decides the key's fate from it alone.

Features that reach outside deserve suspicion

Notifications reach out of your network, so the platform is strict about inputs and loud about anomalies.

  • Webhook destinations cannot point inside. This is the countermeasure against SSRF (Server-Side Request Forgery — tricking a server into calling internal destinations). localhost, private address ranges, the cloud metadata address (169.254.169.254), and URLs with embedded credentials are all rejected before sending, with the reason recorded in the result.
  • Webhooks are signed. A signature over the timestamp and body goes into the headers so receivers can verify the sender. Conversely, the receiver's response body is never stored in results — that closes the door on reading internal data through notification responses.
  • API keys are never stored in plaintext. Keys are 32 characters prefixed with nk_; only a hash is persisted, and the plaintext is shown exactly once at issuance. The admin APIs return whether a channel secret is configured — never the secret itself.
  • Missing configuration stops the platform, loudly. All 13 environment variables fail hard when absent, and consuming products follow the same policy. The worst state for a notification platform is "misconfigured, silently not sending — or sending too much."

How it is actually used — five products on board

As of September 2026, 13 projects are registered, and products with very different shapes send through the platform.

  • The help desk product delegates campaign email delivery and sends staff-facing Slack notifications.
  • The cost-monitoring product sends cost-anomaly alerts through a three-stage fallback: Slack → email → plain webhook. If Slack is down, email still goes out — multi-channel resilience the product gained just by listing destinations.
  • The identity platform (ELN ID) sends signup-confirmation and password-reset emails through notify's templates, which is why changing wording or adding languages requires no change in the identity platform itself.
  • The corporate site — notify's birthplace — uses it for inquiry-receipt notifications.
  • Our flagship AI workspace product has decided to delegate its email sending too (currently stubbed, waiting on connection prerequisites).

Demand keeps arriving after the platform ships. "We want to pick Slack channels in a UI" led to first-classing the Slack app install and choosing from registered channels. "We want auth emails in the user's language" led to per-locale templates. A shared platform is never finished; its consumers grow it.

Behind the scenes — the one time we broke our own monorepo rule

notify lives in our shared-packages monorepo, which had a standing agreement: libraries only, no running services. notify has an API and an admin console, so it violates that agreement.

We decided to house it there anyway — and recorded the rejected alternatives in the decision record. Keeping it inside the corporate site: responsibilities keep bloating, rejected. Housing it in the infrastructure-as-code repository: against that repo's rules, rejected. A dedicated new repository: loses the benefit of sharing the SDK and types as workspace packages, rejected. The more a decision bends your own rules, the more it deserves a written "why" — so that six months later nobody wonders how it got there.

We did keep one principle intact: published packages stay pure. Email transport, HTTP, clocks, and ID generation are all injected; the database implementation lives on the admin-app side. Thanks to that separation, 658 tests (422 platform, 17 SDK, 219 admin) run in seconds without touching AWS — fast enough to run on every change.

For scale: about 5,400 lines of platform code, about 7,500 lines of admin console, about 450 lines of SDK, six architecture decision records, two design documents — roughly two and a half months from extraction to here.

What an outage taught us — a countermeasure that isn't deployed isn't a countermeasure

The platform originally served from our own on-premises site through tunnels. On August 30, 2026, the admin console went completely dark for about 104 seconds.

The application was running fine the whole time. What died was the path: all four tunnel connections dropped at once. The bitter part: we had experienced the same class of outage in July, and the countermeasure (a fallback transport) was already designed — but the change had been left unmerged and was not in production. A countermeasure that is designed but not deployed protects nothing.

The next day we moved delivery to managed hosting (AWS Amplify) and hit three constraints in a row: no manual deployments for SSR, forced Next.js detection that fights prebuilt bundles, and a read-only filesystem breaking cache writes. We worked around each — a dummy app root, "build once in our CI, the platform only unpacks," and pointing cache writes at temp space. That build-once pattern has since been reused for other products' delivery.

Small mix-ups get recorded too, like configuring the generic webhook channel for a Slack destination (Slack cannot parse that payload — use the Slack channel type). One line of lesson, but it reliably saves the next person.

Sending only

notify handles outbound only. Receiving email and turning it into support tickets belongs to a different product (the help desk platform). We drew the product boundary along the direction of communication rather than the feature name — the discussion behind that boundary, and how we amended the monorepo agreement, is a story for another article.

Lessons you can take with you

  • Features every product needs (login, billing, notifications) deserve platform treatment as soon as you build your second product. Features that never pay off for one product (LINE integration) become viable across all of them.
  • Return business outcomes (duplicate, rejected) as branches in a normal response, not as HTTP errors. Reserve exceptions for transport and auth. Use a fixed vocabulary of reasons so callers can branch.
  • Give callers a send ID and make retries safe. "Don't send twice" should be platform mechanics, not caller discipline — and it takes rollback-on-crash and release-on-transient-failure to be true, or your safety mechanism becomes a resend blocker.
  • Features that reach outside need destination validation and fail-loud configuration from day one.
  • A countermeasure only counts once it is in production. Check, mechanically, that designed fixes are not sitting unmerged.

Top comments (0)