Skip to content

Notifications

The Notifications module delivers email, SMS, push, chat (Slack, Discord, Telegram), webhook and in-app notifications, with built-in user preference checking. All channels fire before_* / after_* hooks and check user preferences automatically when user_id is provided.

Channel Method Built-in providers
Email send_email AWS SES, SendGrid, SMTP, Mailgun, Postmark, SparkPost, Brevo, Resend
SMS send_sms AWS SNS, Twilio, Vonage, Plivo, MessageBird, Africa’s Talking, Termii, FastSMS
Push send_push Firebase (FCM), Apple Push (APNs), Huawei Push Kit, Expo
Chat (im) send_im Slack, Discord, Telegram
Webhook send_webhook Generic signed HTTP POST
In-app inbox send_inbox Database
Several at once send_multi Any of the above

Any other provider can be added without changing Kirak: see Adding a Custom Notification Provider.

Install:

Terminal window
pip install "kirak[notifications-ses]" # email (AWS SES)
pip install "kirak[notifications-sns]" # SMS (AWS SNS)
pip install "kirak[notifications-sendgrid]" # email (SendGrid)
pip install "kirak[notifications-firebase]" # push (Firebase FCM)
pip install "kirak[notifications-twilio]" # SMS (Twilio)
pip install "kirak[notifications-apn]" # push (Apple Push)
pip install "kirak[all-notifications]" # all providers

SMTP, Mailgun, Postmark, SparkPost, Brevo, Resend, Vonage, Plivo, MessageBird, Africa’s Talking, Termii, FastSMS, Huawei, Expo, Slack, Discord, Telegram and webhooks call the provider’s HTTP API directly and need no extra install.

Enable:

app = create_kirak_app(models_path="...", modules=["notifications"])

# From any hook or route:
await kirak.notifications.send_email({
"to": "alice@example.com",
"subject": "Welcome!",
"html_body": "<h1>Welcome to the platform</h1>",
"text_body": "Welcome to the platform",
})
await kirak.notifications.send_sms({
"to": "+14155552671",
"message": "Your verification code is 482913",
})
await kirak.notifications.send_push({
"to": "device-fcm-token-here",
"title": "New message",
"body": "You have a new message from Alice",
"data": {"chat_id": 42},
})
await kirak.notifications.send_im({
"to": "C0123456789", # Slack channel id
"text": "Deploy finished",
})
await kirak.notifications.send_webhook({
"event": "order.shipped",
"data": {"order_id": 1234},
})
await kirak.notifications.send_inbox({
"user_id": 42,
"type": "order_shipped",
"title": "Order Shipped!",
"message": "Your order #1234 is on its way.",
"data": {"order_id": 1234},
"link": "/orders/1234",
})

result = await kirak.notifications.send_email({
# Required
"to": "alice@example.com",
# Direct mode: provide subject + html_body / text_body inline
"subject": "Welcome to Kirak",
"html_body": "<p>Hello Alice!</p>",
"text_body": "Hello Alice!",
# Template mode (overrides direct content when template_name is set)
# "template_name": "welcome", # no extension -- resolved via assets/notifications/templates/email/
# "template_params": {"first_name": "Alice", "company": "Acme"},
# Attachments (template mode only)
# "attachments": True, # boolean flag to enable attachments
# "attachment_names": ["invoice.pdf"], # filenames resolved via "attachment_base_path" in kirak.json's notifications section
# Optional
"user_id": 42, # enables preference checking
"event_type": "welcome", # enables event-level preference checking
"from_email": "no-reply@acme.com", # override default sender (email_from_address in kirak.json)
})

Templates are Python .py files resolved through asset resolution: Kirak looks for {assets_path or "assets"}/notifications/templates/email/{template_name}.py in your project first, falling back to the core package’s own bundled templates. template_name is passed without the .py extension – the operation appends it. Each template file must export a get_template(params: dict) -> dict function that returns subject, html_body, and text_body:

app/templates/welcome.py
def get_template(params: dict) -> dict:
name = params.get("first_name", "there")
return {
"subject": f"Welcome, {name}!",
"html_body": f"<h1>Hi {name}, thanks for signing up!</h1>",
"text_body": f"Hi {name}, thanks for signing up!",
}
email_templates/
+-- welcome.py
+-- password_reset.py
+-- order_confirmation.py

Kirak ships three built-in templates (welcome.py, reset_password.py, verification.py) used internally by the auth module. Override one by placing a same-named file at assets/notifications/templates/email/ in your project – do not edit the core package’s copies directly.

Each channel (email, SMS, push, im, webhook) has its own set of provider instances and its own default, declared in kirak.json. An instance has a name (the key) and a type (which implementation to use). A channel with no providers is simply unavailable. Several instances can be active at once, including two of the same type.

{
"notifications": {
"email_from_address": "no-reply@acme.com",
"email": {
"default_provider": "ses",
"providers": {
"ses": { "type": "aws_ses", "aws_region": "us-east-1" },
"smtp": { "type": "smtp", "smtp_host": "smtp.gmail.com", "smtp_port": 587 }
}
},
"sms": {
"default_provider": "sns",
"providers": {
"sns": { "type": "aws_sns", "aws_region": "us-east-1" },
"twilio": { "type": "twilio", "from_number": "+15551234567" }
}
},
"push": {
"default_provider": "fcm",
"providers": {
"fcm": { "type": "firebase", "firebase_credentials_path": "/path/to/serviceAccount.json",
"firebase_project_id": "my-firebase-project" },
"ios": { "type": "apn", "apn_key_path": "/path/to/AuthKey.p8", "apn_key_id": "ABC123DEFG",
"apn_team_id": "TEAM123456", "apn_bundle_id": "com.acme.app" },
"huawei": { "type": "huawei", "app_id": "1234567" }
}
},
"im": {
"default_provider": "ops_slack",
"providers": {
"ops_slack": { "type": "slack" },
"eng_discord": { "type": "discord" },
"alerts_tg": { "type": "telegram" }
}
},
"webhook": {
"default_provider": "billing",
"providers": {
"billing": { "type": "generic", "url": "https://billing.acme.internal/hooks/kirak" }
}
}
}
}
Channel Built-in type Settings in the instance entry
email aws_ses aws_region
email sendgrid (pip install "kirak[notifications-sendgrid]") –
email smtp smtp_host (required), smtp_port (587), smtp_use_tls (true)
email mailgun domain (required), region (us or eu, default us)
email postmark message_stream (default outbound)
email sparkpost region (us or eu, default us)
email brevo –
email resend –
sms aws_sns aws_region
sms twilio (pip install "kirak[notifications-twilio]") from_number or messaging_service_sid (else sms_from_number)
sms vonage from_number (else sms_from_number)
sms plivo from_number (else sms_from_number)
sms messagebird from_number (the originator; else sms_from_number)
sms africastalking username (required; sandbox uses the sandbox API), from_number (optional sender ID; else sms_from_number, else Africa’s Talking’s shared sender)
sms termii base_url (your account’s API URL from the dashboard; default https://api.ng.termii.com), channel (generic or dnd, default generic; use dnd for OTPs once Termii activates it), from_number (the sender ID; else sms_from_number)
sms fastsms from_number (else sms_from_number)
push firebase (pip install "kirak[notifications-firebase]") firebase_credentials_path, firebase_project_id
push apn (pip install "kirak[notifications-apn]") apn_key_path, apn_key_id, apn_team_id, apn_bundle_id (all required), apn_use_sandbox (false)
push huawei app_id (required)
push expo – (tokens are Expo push tokens, ExponentPushToken[...])
im slack, discord, telegram –
webhook generic url, allow_url_override (false), timeout (10)
  • Default: a call without provider uses that channel’s default_provider.
  • Per call: pass "provider": "<instance name>" to send_email, send_sms, send_push, send_im or send_webhook (and in the JSON body of the matching HTTP routes). The value is the instance name (smtp), not the type, and it must belong to the same channel. With send_multi, put it in that channel’s entry under data.
  • Allow-list: only instances listed in kirak.json can be used. Anything else fails with PROVIDER_NOT_CONFIGURED (400), as does calling a channel that has no providers.
  • Secrets are never read from kirak.json. Each instance reads KIRAK_NOTIFICATION_<CHANNEL>_<INSTANCE>_<FIELD>:
Type Variables (instance named after its type shown)
aws_ses KIRAK_NOTIFICATION_EMAIL_AWS_SES_ACCESS_KEY, ..._SECRET_KEY (optional; boto3 default credential chain when unset)
sendgrid KIRAK_NOTIFICATION_EMAIL_SENDGRID_API_KEY
smtp KIRAK_NOTIFICATION_EMAIL_SMTP_USERNAME, ..._PASSWORD (optional)
mailgun KIRAK_NOTIFICATION_EMAIL_MAILGUN_API_KEY
postmark KIRAK_NOTIFICATION_EMAIL_POSTMARK_SERVER_TOKEN
sparkpost KIRAK_NOTIFICATION_EMAIL_SPARKPOST_API_KEY (needs the Transmissions: Read/Write permission)
brevo KIRAK_NOTIFICATION_EMAIL_BREVO_API_KEY
resend KIRAK_NOTIFICATION_EMAIL_RESEND_API_KEY (a sending-only key is enough)
aws_sns KIRAK_NOTIFICATION_SMS_AWS_SNS_ACCESS_KEY, ..._SECRET_KEY (optional)
twilio KIRAK_NOTIFICATION_SMS_TWILIO_ACCOUNT_SID, ..._AUTH_TOKEN
vonage KIRAK_NOTIFICATION_SMS_VONAGE_API_KEY, ..._API_SECRET
plivo KIRAK_NOTIFICATION_SMS_PLIVO_AUTH_ID, ..._AUTH_TOKEN
messagebird KIRAK_NOTIFICATION_SMS_MESSAGEBIRD_ACCESS_KEY
africastalking KIRAK_NOTIFICATION_SMS_AFRICASTALKING_API_KEY
termii KIRAK_NOTIFICATION_SMS_TERMII_API_KEY
fastsms KIRAK_NOTIFICATION_SMS_FASTSMS_TOKEN
huawei KIRAK_NOTIFICATION_PUSH_HUAWEI_CLIENT_SECRET
expo KIRAK_NOTIFICATION_PUSH_EXPO_ACCESS_TOKEN (optional; only when the Expo project enables push security)
slack, discord, telegram KIRAK_NOTIFICATION_IM_<INSTANCE>_BOT_TOKEN, e.g. KIRAK_NOTIFICATION_IM_OPS_SLACK_BOT_TOKEN
generic KIRAK_NOTIFICATION_WEBHOOK_<INSTANCE>_SECRET_KEY (optional; unsigned when unset)
  • Sender identity (email_from_address, email_from_name, sms_from_number) and attachment_base_path stay at the top level of "notifications" and apply to every instance.
  • Phone numbers are passed in international format (+447700900000). Vonage, MessageBird, Termii and FastSMS take the number without the +; Kirak removes it for them.
  • Attachments (send_email with attachments) are sent by smtp, aws_ses, mailgun, postmark, sparkpost, brevo and resend.

A provider that cannot deliver raises a KirakException – EMAIL_ERROR, SMS_ERROR, PUSH_ERROR, IM_ERROR or WEBHOOK_ERROR (HTTP 500), with the gateway’s own code in details["provider_code"] when it has one. A failed send never comes back as a success response. For push, some tokens failing is a normal partial result reported in failure_count; it raises only when no token was delivered.


result = await kirak.notifications.send_sms({
"to": "+14155552671",
"message": "Your code is 482913. Expires in 10 minutes.",
"user_id": 42, # optional: enables preference checking
"event_type": "otp", # optional: event-level preference check
})

Provider: the SMS channel’s default instance, or the one named by "provider". See Providers.


result = await kirak.notifications.send_push({
"to": "fcm-device-token", # device token from Firebase SDK
"title": "New order",
"body": "Order #1234 has been confirmed.",
"data": {"order_id": 1234, "type": "order_confirmed"}, # custom payload
"user_id": 42, # optional: enables preference checking
})

Provider: the push channel’s default instance, or the one named by "provider". See Providers. Each firebase instance initialises its own named Firebase app, so two instances can use different projects.


Sends a chat message to a workspace or messaging app.

result = await kirak.notifications.send_im({
"to": "C0123456789", # required: channel, user or chat id in the platform's own addressing
"text": "Deploy finished", # required
"blocks": [...], # optional: rich layout, used by Slack, ignored by Discord and Telegram
"provider": "ops_slack", # optional: an instance in notifications.im
"user_id": 42, # optional: enables preference checking (channel name "im")
})
Provider to is Notes
slack channel id (C...) or user id (U...) chat.postMessage with the bot token (xoxb-...); the bot must be in private channels
discord channel id the bot needs permission to post in that channel
telegram chat id or @channelusername the chat must have started the bot

Bot tokens come from KIRAK_NOTIFICATION_IM_<INSTANCE>_BOT_TOKEN. A platform error (for example channel_not_found) raises IM_ERROR with the platform’s code in details["provider_code"].


Delivers an event to an external system as a signed HTTP POST. Each configured instance is a named destination, so an app can notify a billing system and a data warehouse independently.

result = await kirak.notifications.send_webhook({
"event": "order.shipped", # required
"data": {"order_id": 1234}, # optional payload
"provider": "billing", # optional: an instance in notifications.webhook
})
# result["data"] == {"message": "Webhook delivered", "status_code": 200}

The request body is {"event": ..., "data": ..., "timestamp": <unix seconds>}. When the instance has a secret_key, every request also carries:

  • X-Kirak-Timestamp: the same timestamp.
  • X-Kirak-Signature: sha256= followed by the hex HMAC-SHA256 of "<timestamp>.<raw body>", keyed by the secret.

A receiver recomputes the HMAC over the raw bytes it received and compares in constant time; rejecting old timestamps stops replays.

import hashlib, hmac
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)

A non-2xx response or a connection failure raises WEBHOOK_ERROR, with details["status_code"] when the receiver answered. There is no built-in retry; for retry with backoff, enqueue the call through the scheduler.

The destination is the instance’s url. Passing "to" to send to a different URL is refused (WEBHOOK_URL_NOT_ALLOWED, 400) unless that instance sets "allow_url_override": true, because a caller-controlled URL lets a request reach internal services.


Creates an in-app notification stored in the database (visible in the notification inbox). send_notification is the older name for the same call and keeps working; new code should use send_inbox. The after_send_notification hook fires for both.

result = await kirak.notifications.send_inbox({
"user_id": 42, # required
"type": "comment_liked", # your event type string
"title": "New like",
"message": "Alice liked your comment.",
"data": {"post_id": 7, "comment_id": 99}, # arbitrary metadata
"link": "/posts/7#comment-99", # deep link
"event_type": "social", # optional: event-level preference check
})

Requires: A notifications model in your models.json:

{
"notifications": {
"table": "notifications_messages",
"schema": {
"user_id": { "type": "integer", "required": true },
"type": { "type": "string" },
"title": { "type": "string" },
"message": { "type": "text" },
"data": { "type": "json" },
"link": { "type": "string" },
"read_at": { "type": "timestamp" }
}
}
}

Deliver to multiple channels in one call. Each channel is checked against user preferences independently.

result = await kirak.notifications.send_multi({
"user_id": 42,
"channels": ["email", "im", "in_app"],
"event_type": "order_shipped", # optional: event-level check runs first
"data": {
"email": {
"to": "alice@example.com",
"subject": "Your order shipped",
"template_name": "order_shipped",
"template_params": {"order_id": 1234},
},
"im": {
"provider": "ops_slack", # optional, per channel
"to": "C0123456789",
"text": "Order #1234 shipped",
},
"in_app": {
"type": "order_shipped",
"title": "Order Shipped!",
"message": "Your order #1234 is on its way.",
"link": "/orders/1234",
},
},
})

Response:

Each channel’s entry is that channel’s own response envelope on success, or {"success": false, "error": <code>, "message": ...} when that channel failed or the user opted out.

{
"statusCode": 200,
"status": "success",
"message": "Success",
"data": {
"results": {
"email": {
"statusCode": 200, "status": "success", "message": "Success",
"data": { "message": "Email sent via AWS SES", "id": "ses-message-id" }
},
"im": { "success": false, "error": "PROVIDER_NOT_CONFIGURED", "message": "notifications_im provider 'ops_slack' is not configured. Active providers: none" },
"in_app": {
"statusCode": 200, "status": "success", "message": "Success",
"data": { "id": 99, "title": "Order Shipped!" }
}
}
}
}

The call succeeds if at least one channel succeeded, so check each entry in results. If every channel fails it raises MULTI_SEND_FAILED (500) with the per-channel results in details.

Channels: email, sms, push, im, webhook, in_app.


The inbox provides CRUD operations on stored in-app notifications. Each method takes one params dict and returns the response envelope:

inbox = kirak.notifications.inbox
# Fetch unread notifications
unread = (await inbox.fetch_unread({"user_id": 42, "limit": 50}))["data"]
# Fetch all notifications (read + unread)
all_notifs = (await inbox.fetch_all({"user_id": 42, "limit": 50, "offset": 0}))["data"]
# Unread count (for badge)
count = (await inbox.count_unread({"user_id": 42}))["data"]["count"]
# Mark single notification as read (user_id is required for ownership check)
await inbox.mark_read({"notification_id": 99, "user_id": 42})
# Mark all read
await inbox.mark_all_read({"user_id": 42})
# Also: fetch_by_type({"user_id", "type"}), mark_unread({"notification_id", "user_id"}),
# delete({"notification_id", "user_id"}), delete_all_read({"user_id"})

Requires the notifications model defined in models.json and the Kirak instance initialized with db_pool.


Preferences allow users to opt out of notification channels or specific event types. They are checked automatically when user_id is provided: a send to a user who opted out raises PREFERENCE_DENIED (403) with details["reason"] such as user_opted_out_of_email or user_opted_out_of_marketing, and send_multi skips that channel ({"success": false, "error": "user_opted_out"} in its results). Like the inbox, each method takes one params dict and returns the envelope:

prefs = kirak.notifications.preferences
# Check if channel is enabled for user
enabled = (await prefs.is_channel_enabled({"user_id": 42, "channel": "email"}))["data"]["enabled"]
# Check if specific event is enabled
enabled = (await prefs.is_event_enabled({"user_id": 42, "event_type": "marketing"}))["data"]["enabled"]
# Opt out, read everything, reset
await prefs.set_channel_preference({"user_id": 42, "channel": "sms", "enabled": False})
await prefs.set_event_preference({"user_id": 42, "event_type": "marketing", "enabled": False})
all_prefs = (await prefs.get_all_preferences({"user_id": 42}))["data"] # {"channels": {...}, "events": {...}}
await prefs.reset_to_defaults({"user_id": 42})

A user can opt out of the email, sms, push and in_app channels, and of any event_type. The im and webhook channels have no per-user opt-out: they post to a channel or a system, not to a user.

When the notification_preferences model is not present, NoOpPreferences is used (all notifications enabled, no DB calls).

notification_preferences model:

{
"notification_preferences": {
"table": "notifications_preferences",
"schema": {
"user_id": { "type": "integer", "required": true },
"preference_type": { "type": "string", "required": true, "enum": ["channel", "event"] },
"channel": { "type": "string", "enum": ["email", "sms", "push", "in_app"] },
"event_type": { "type": "string" },
"enabled": { "type": "boolean", "required": true, "default": true }
}
}
}

@kirak.notifications.hook("after_send_email")
async def log_email_sent(result):
# Hook receives result only on successful send
# (send_email raises KirakException on failure, so this hook is only called for successful sends)
message_id = result["data"]["id"] # providers return "id", not "message_id"
# Log to audit trail...
return result

Every send method fires before_<method> and after_<method>: send_email, send_sms, send_push, send_im and send_webhook (for example after_send_im). The in-app call fires after_send_notification.


The notifications module mounts HTTP endpoints when included in modules. All endpoints require a Authorization: Bearer <token> header. A token revoked by logout is refused with TOKEN_REVOKED (401); if the revocation list cannot be read, the request is refused with AUTH_UNAVAILABLE (503) rather than accepted.

Two auth tiers:

  • Send endpoints – require admin, system, or superadmin role. These submit requests to paid external providers (SES, SNS, Twilio, Firebase, Slack and others) and should only be called by trusted system services.
  • Inbox / Preferences endpoints – require any valid JWT. The user_id in the request must match the JWT’s subject (sub claim). Admins may pass any user_id.
Method Path Auth Description
POST /notifications/send-email admin/system Send email via HTTP
POST /notifications/send-sms admin/system Send SMS via HTTP
POST /notifications/send-push admin/system Send push notification via HTTP
POST /notifications/send-im admin/system Send a chat message (Slack, Discord, Telegram)
POST /notifications/send-webhook admin/system Deliver a signed webhook
POST /notifications/send-inbox admin/system Create in-app notification
POST /notifications/send-notification admin/system Alias of /send-inbox
POST /notifications/send-multi admin/system Multi-channel send
GET /notifications/inbox user (own) Fetch all notifications – query: user_id, limit, offset
GET /notifications/inbox/unread user (own) Fetch unread notifications – query: user_id, limit
GET /notifications/inbox/count/unread user (own) Get unread count – query: user_id
PUT /notifications/inbox/{notification_id}/read user (own) Mark notification as read – query: user_id
PUT /notifications/inbox/read-all user (own) Mark all notifications as read – query: user_id
DELETE /notifications/inbox/{notification_id} user (own) Delete a notification – query: user_id
GET /notifications/preferences user (own) Get all preferences – query: user_id
PUT /notifications/preferences/channel/{channel} user (own) Update channel preference – body: {"user_id": 42, "enabled": false}
PUT /notifications/preferences/event/{event_type} user (own) Update event-level preference – body: {"user_id": 42, "enabled": false}
POST /notifications/preferences/reset user (own) Reset preferences to defaults – body: {"user_id": 42}

The shared contract, the credential check (check()), entry points and testing are covered in Adding a Provider.

Subclass the base class for the channel (EmailProvider, SMSProvider, PushProvider, IMProvider or WebhookProvider in kirak/notifications/providers/base.py), register the type for that channel, and list an instance in kirak.json.

from kirak.core.exceptions import KirakException
from kirak.notifications.providers.base import EmailProvider
class AcmeMailProvider(EmailProvider):
TYPE_NAME = "acme_mail" # the "type" in kirak.json
SECRET_FIELDS = ("api_key",) # read from KIRAK_NOTIFICATION_EMAIL_<INSTANCE>_API_KEY
def __init__(self, config, notifications=None):
super().__init__(config, notifications)
self._require("api_key", "domain") # fail early on missing config
async def send(self, to_email, from_email, subject, html_body, text_body, attachments=None, **kwargs):
try:
... # call the gateway
except Exception as e:
raise KirakException(str(e), code="EMAIL_ERROR", status_code=500) from e
return {"id": message_id}
async def on_kirak_ready(kirak):
kirak.notifications.register_provider("email", "acme_mail", AcmeMailProvider) # channel first
"email": { "default_provider": "acme", "providers": { "acme": { "type": "acme_mail", "domain": "mail.example.com" } } }

Providers must raise KirakException on failure (see Failures). register_provider also works as a decorator, raises ValueError if the type name is taken in that channel (including by a built-in) and TypeError if the class does not subclass the channel’s base class. A provider can instead be published as a pip package with an entry point in the group kirak.notifications_email_providers, kirak.notifications_sms_providers, kirak.notifications_push_providers, kirak.notifications_im_providers or kirak.notifications_webhook_providers, so users need only pip install plus a type in kirak.json:

[project.entry-points."kirak.notifications_email_providers"]
acme_mail = "your_package.acme:AcmeMailProvider"

To contribute a provider to Kirak core, add the class under kirak/notifications/services/, declare it with a ProviderSpec in the channel’s entry of CHANNEL_PROVIDERS in kirak/catalog/specs/notifications.py (the channel’s built-in table is built from it; see Adding a Provider), and add tests.