Pluggable Channels
Notification channels are pluggable adapters. The platform defines
an interface (NotificationChannel) and ships two built-in
implementations (WebSocket, Browser Push). Every other channel — email,
SMS, Telegram, Slack, Discord, Webhook — is installed as a module that
registers an adapter conforming to this interface.
This architecture lets the platform support any delivery channel without modifying kernel code.
NotificationChannel Interface
Every channel adapter must implement six components:
| Component | Type | Description |
|---|---|---|
identifier | string | Unique, lowercase slug. Used as the channel field in send(). Examples: email, sms, telegram. Cannot contain spaces or uppercase. |
name | string | Human-readable display name shown in Settings → Notifications. Example: "Email (SendGrid)". |
icon | string | URL to the channel icon (32×32 px PNG or SVG). Shown in Settings and notification history. |
setupComponent | React component | UI rendered in Settings → Notifications → Connect. Typically contains API key inputs, webhook URL fields, and a "Verify" button. |
send | (recipient, message) => Promise<void> | The delivery method. Called by the Notify Service worker for each notification. May throw on failure — the worker handles retry. |
validate | (config) => Promise<boolean> | Called when the admin saves provider credentials. Returns true if the configuration is valid (e.g. SendGrid API key is accepted). Used to detect misconfiguration before the first real send attempt. |
Go Interface (Server-Side)
The Notify Service calls adapters via the Go interface:
// services/notify/internal/channels/channel.go
type NotificationChannel interface {
Identifier() string
Name() string
IconURL() string
// Validate credentials before first use
Validate(ctx context.Context, config ChannelConfig) error
// Deliver a notification
Send(ctx context.Context, recipient Recipient, msg Message) error
// UI component served to the frontend for setup
SetupComponentPath() string
}
type Recipient struct {
UserID string
TenantID string
Target string // email address, phone number, chat ID, webhook URL, etc.
}
type Message struct {
Subject string
Body string
Priority string
Metadata map[string]any
}
Declaring a Channel in the Manifest
An adapter module declares its channel in module.manifest.json:
{
"name": "@acme/notify-sendgrid",
"version": "1.0.0",
"notificationChannels": [
{
"identifier": "email",
"name": "Email (SendGrid)",
"icon": "https://cdn.acme.com/icons/sendgrid-32.png",
"setupComponent": "./src/components/SendGridSetup.tsx",
"sendHandler": "SendGridChannel"
}
]
}
When the admin installs this adapter module, the Notify Service
automatically loads the SendGridChannel handler and registers it
under the email identifier. The channel immediately appears in
Settings → Notifications.
Register a Channel via REST
Channel adapters are registered programmatically when the module installs:
POST https://api.septemcore.com/v1/notifications/channels
Authorization: Bearer <access_token>
Content-Type: application/json
{
"identifier": "email",
"name": "Email (SendGrid)",
"icon": "https://cdn.acme.com/icons/sendgrid-32.png",
"config": {
"apiKey": "SG.xxxxxxxxxxxxxxxxxxxxxxxx",
"fromAddress": "[email protected]",
"fromName": "Acme Platform"
}
}
Response 201 Created:
{
"channelId": "01j9pachx100000000000000",
"identifier": "email",
"name": "Email (SendGrid)",
"status": "verified",
"registeredAt": "2026-04-15T10:30:00.000Z"
}
The status field reflects the result of the validate() call:
| Status | Meaning |
|---|---|
verified | validate() passed — credentials are valid |
unverified | validate() skipped (offline registration) |
invalid | validate() failed — check config fields |
SDK — registerChannel() and listChannels()
// Register a channel adapter programmatically
await kernel.notify().registerChannel({
identifier: 'telegram',
name: 'Telegram',
icon: 'https://cdn.acme.com/icons/telegram-32.png',
config: {
botToken: '7123456789:AAHxxxxxxxx',
chatId: '-100123456789',
},
});
// List all registered channels for the current tenant
const channels = await kernel.notify().listChannels();
// [
// { identifier: 'websocket', name: 'WebSocket', status: 'built-in' },
// { identifier: 'push', name: 'Browser Push', status: 'built-in' },
// { identifier: 'email', name: 'Email (SendGrid)', status: 'verified' },
// { identifier: 'telegram', name: 'Telegram', status: 'verified' },
// ]
List Channels via REST
GET https://api.septemcore.com/v1/notifications/channels
Authorization: Bearer <access_token>
{
"data": [
{
"identifier": "websocket",
"name": "WebSocket",
"icon": null,
"status": "built-in"
},
{
"identifier": "push",
"name": "Browser Push",
"icon": null,
"status": "built-in"
},
{
"identifier": "email",
"name": "Email (SendGrid)",
"icon": "https://cdn.acme.com/icons/sendgrid-32.png",
"status": "verified",
"registeredAt": "2026-04-15T10:30:00.000Z"
}
]
}
Channel Availability in send()
The channel field in send() must match the identifier of a
registered channel. An unregistered channel returns 400 Bad Request:
{
"type": "https://api.septemcore.com/problems/validation-error",
"status": 400,
"detail": "Channel 'whatsapp' is not registered for this tenant.",
"code": "CHANNEL_NOT_FOUND"
}
Built-in channels (websocket, push) are always available without
registration.
Implementing a Custom Adapter
To add a channel not in the standard list (e.g. a proprietary internal messaging system):
- Create a module that implements
NotificationChannelin Go. - Declare it in
module.manifest.jsonundernotificationChannels. - Install the module in the tenant.
- The channel is immediately available in
send({ channel: 'my-channel' }).
The Notify Service never has hard-coded knowledge of specific providers. Adding or removing a provider is purely a module install/uninstall operation — no kernel changes required.
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
| Channel identifier not registered | 400 | CHANNEL_NOT_FOUND |
Channel validate() failed on register | 422 | CHANNEL_CONFIG_INVALID |
Duplicate identifier for this tenant | 409 | CHANNEL_ALREADY_EXISTS |
identifier contains uppercase or spaces | 400 | validation-error |