Skip to main content
Version: Next (nightly)

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, so 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": "purchases.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.