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 |
|---|---|---|
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:
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 providersSMTP, 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"])Quick Start
Section titled “Quick Start”# 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",})send_email
Section titled “send_email”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)})Email Templates
Section titled “Email Templates”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:
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.pyKirak 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.
Providers
Section titled “Providers”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 |
|---|---|---|
aws_ses |
aws_region |
|
sendgrid (pip install "kirak[notifications-sendgrid]") |
– | |
smtp |
smtp_host (required), smtp_port (587), smtp_use_tls (true) |
|
mailgun |
domain (required), region (us or eu, default us) |
|
postmark |
message_stream (default outbound) |
|
sparkpost |
region (us or eu, default us) |
|
brevo |
– | |
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
provideruses that channel’sdefault_provider. - Per call: pass
"provider": "<instance name>"tosend_email,send_sms,send_push,send_imorsend_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. Withsend_multi, put it in that channel’s entry underdata. - Allow-list: only instances listed in
kirak.jsoncan be used. Anything else fails withPROVIDER_NOT_CONFIGURED(400), as does calling a channel that has no providers. - Secrets are never read from
kirak.json. Each instance readsKIRAK_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) andattachment_base_pathstay 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_emailwithattachments) are sent bysmtp,aws_ses,mailgun,postmark,sparkpost,brevoandresend.
Failures
Section titled “Failures”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.
send_sms
Section titled “send_sms”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.
send_push
Section titled “send_push”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.
send_im
Section titled “send_im”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"].
send_webhook
Section titled “send_webhook”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.
send_inbox (In-App)
Section titled “send_inbox (In-App)”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" } } }}send_multi (Multi-Channel)
Section titled “send_multi (Multi-Channel)”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.
Notification Inbox
Section titled “Notification Inbox”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 notificationsunread = (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 readawait 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.
User Preferences
Section titled “User Preferences”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 userenabled = (await prefs.is_channel_enabled({"user_id": 42, "channel": "email"}))["data"]["enabled"]
# Check if specific event is enabledenabled = (await prefs.is_event_enabled({"user_id": 42, "event_type": "marketing"}))["data"]["enabled"]
# Opt out, read everything, resetawait 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 resultEvery 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.
HTTP Endpoints
Section titled “HTTP Endpoints”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, orsuperadminrole. 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_idin the request must match the JWT’s subject (subclaim). Admins may pass anyuser_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} |
Adding a Custom Notification Provider
Section titled “Adding a Custom Notification Provider”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 KirakExceptionfrom 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.