Skip to main content

Add a notification channel

The channels that ship (in-app, browser push, email, Discord, Slack, generic webhook, SMS) are covered in the notifications guide. A channel is one transport for a delivered notification. Adding a new one is code, not a manifest: the channel registry is fixed at build time, so a new channel is a small in-tree driver plus a registry entry, unlike a sync source or a machine driver you paste into a workspace.

If your goal is only to get Cobblr events into your own service, you probably don't need a new channel. The generic webhook channel already POSTs a stable JSON envelope to any URL a user configures. Reach for a new channel when a transport needs its own message format (Slack blocks, a Discord embed, an SMS body) or its own delivery call.

The interface

A channel implements one method. The dispatcher writes the notification row, looks up which channels a user enabled for the event type, and calls deliver on each:

export interface Channel {
readonly name: ChannelName;
/** Deliver. Resolve true on success; the caller records which
* channels succeeded in notifications.delivered_via. */
deliver(event: ChannelEvent): Promise<boolean>;
}

The ChannelEvent carries the message, an optional deep link, the priority, and the user's per-subscription config (the JSONB where a user stores the transport target, like a webhook URL or an address). A driver reads what it needs from config, and returns false if a required field is missing rather than throwing.

A minimal channel

The Slack channel is a good template: read the config, format a message, POST it, return whether it worked.

import { postJson } from "./http-helpers.js";
import type { Channel, ChannelEvent } from "./types.js";

interface SlackConfig {
webhook_url?: string;
}

function readConfig(payload: unknown): SlackConfig | null {
if (!payload || typeof payload !== "object") return null;
const cfg = payload as SlackConfig;
if (typeof cfg.webhook_url !== "string" || cfg.webhook_url.length === 0) return null;
if (!cfg.webhook_url.startsWith("https://hooks.slack.com/")) return null;
return cfg;
}

export const slackChannel: Channel = {
name: "slack",
async deliver(event: ChannelEvent): Promise<boolean> {
const cfg = readConfig(event.subscriptionConfig);
if (!cfg?.webhook_url) return false;
return postJson({
url: cfg.webhook_url,
body: { text: `[${event.eventType}] ${event.message}` },
channelName: "slack",
});
},
};

Then register it in the dispatcher's channel registry under its name, add the name to the channel enum, and give it a default in the notification catalog so it appears in a user's subscription settings.

Validate the config, and guard the URL

Two things every channel that calls out to a user-supplied URL must do:

  • Reject a malformed config. Return null from a readConfig helper and false from deliver rather than throwing, so one bad subscription doesn't break a fan-out.
  • Block internal addresses. A user-set URL that points at a private host turns every fired notification into a probe of the internal network. The webhook channel resolves the hostname and refuses private and loopback addresses; reuse that guard for any channel that POSTs to an arbitrary URL. Slack and Discord are exempt only because they validate against a fixed public host prefix.

The stable webhook envelope

If you are on the receiving end rather than writing a channel, the generic webhook channel already sends a documented shape you can build against:

{
"notification_id": "...",
"event_type": "order_arrived",
"message": "...",
"link_url": "...",
"priority": "normal",
"org_id": "...",
"user_id": "...",
"occurred_at": "2026-07-11T00:00:00Z"
}

A channel is one of the few extension points that is in-tree; see the contribution guide for module isolation and the definition of done before you open a change.