From f155773c47701571869baff5a61a9d6a4717a70d Mon Sep 17 00:00:00 2001
From: coo1estguy <70365929+coolestcodeguy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 14:51:14 +0530
Subject: [PATCH 1/2] docs: update README layout and add reliability testing
overview [skip ci]
- Move Agent-operable section and AI demo higher in documentation flow
- Add Reliability & Chaos Engineering section highlighting Redis stream recovery and load testing
- Reorder quickstart and core features for improved readability
---
README.md | 186 +++++++++++++++++++++++++++---------------------------
1 file changed, 92 insertions(+), 94 deletions(-)
diff --git a/README.md b/README.md
index bfb552a..837c529 100644
--- a/README.md
+++ b/README.md
@@ -14,12 +14,6 @@ Self-hosted notification infrastructure for product notifications. One API call
---
-https://github.com/user-attachments/assets/4dff98bb-37d3-44b4-bf46-9607c1cd89b5
-
-[▶️ Watch the AI demo](assets/ai_demo.mp4) - this is link to raw video file
-
----
-
### The first notification is easy
```ts
@@ -105,69 +99,61 @@ flowchart TD
---
-## Battle-tested for production
+## Agent-operable
-> **Battle-tested in production:** notifkit powers production notification pipelines handling **thousands of emails, push notifications, and OTPs every day.**
->
-> It is the infrastructure we built because we needed it ourselves — rather than spending months reinventing distributed notification plumbing or paying SaaS tolls per alert.
+https://github.com/user-attachments/assets/4dff98bb-37d3-44b4-bf46-9607c1cd89b5
-**Your servers. Your providers. Your data. Zero notification SaaS markups.**
+[▶️ Watch the AI demo](assets/ai_demo.mp4) - this is link to raw video file
-### Reliability & Chaos Engineering
+**notifkit isn't just an API your application can call — your AI agent can operate it directly.**
-Because notification delivery is mission-critical, every pipeline component is tested against extreme failure conditions:
+Connect the notifkit MCP server ([`@notifkit/mcp`](./packages/mcp)) to Claude Code, Cursor, Claude Desktop, Gemini, or any MCP-compatible agent:
-```mermaid
-flowchart LR
- S1["Redis Streams"] -->|"Kill Worker (SIGKILL)"| M1["Auto-Claim and Replay"] --> O1["Zero Lost Messages"]
- S2["Connection Loss"] -->|"Drop DB / Redis"| M2["Auto-Reconnect / Retry"] --> O2["In-Flight State Intact"]
- S3["10k+ Messages"] -->|"Burst"| M3["Concurrency and Limits"] --> O3["Flat Memory, No Leaks"]
-
- classDef fault stroke:#ef4444,stroke-width:2px
- classDef guard stroke:#6366f1,stroke-width:2px
- classDef result stroke:#22c55e,stroke-width:2px
- class S1,S2,S3 fault
- class M1,M2,M3 guard
- class O1,O2,O3 result
+```bash
+npx -y @notifkit/mcp
```
-- **Chaos Monkey Testing (`tests/chaos/crash.test.ts`)**: Background worker processes are randomly terminated with `SIGKILL` during active, high-throughput message streaming. Consumer group Pending Entries List (PEL) re-claims guarantee **zero lost messages** and seamless failover.
-- **Infrastructure Recovery Testing (`tests/chaos/recovery.test.ts`)**: PostgreSQL and Redis connections are forcefully severed and restored under live traffic. Verifies automatic client reconnection, worker backpressure, and durable state resumption.
-- **High-Throughput Load Testing (`tests/chaos/load.test.ts`)**: Stressed with bursts of **10,000+ notifications** across parallel worker pools, verifying queue drain velocity, sliding-window rate limiters, and flat memory profiles without leaks.
-- **Race Conditions & Concurrency (`tests/race-conditions.test.ts`, `tests/idempotency.test.ts`)**: Hardened against concurrent duplicate dispatches, overlapping quiet-hour boundary evaluations, atomic user updates, and 24-hour idempotency key deduplication.
-- **100% Real Ephemeral Containers**: Unit, integration, and chaos test suites execute against real PostgreSQL and Redis containers via [Testcontainers](https://testcontainers.com), eliminating mocks for core storage and streaming primitives.
+### Ask your agent
+
+```text
+You: Why didn't usr_9182 receive their password reset?
+
+Agent: The notification was suppressed because usr_9182's email
+ address has a hard-bounce suppression from yesterday.
+```
+
+Your application and your AI agents use the **same notification infrastructure**:
+
+- **Send & dispatch** — Send one-off notifications or campaigns to users, lists, and segments (`send_notification`, `send_campaign`)
+- **Investigate & triage** — Diagnose delivery issues by inspecting message histories, provider responses, and quiet hours (`get_delivery_logs`, `get_notification`)
+- **Schedule & cancel** — Schedule future sends and cancel pending notifications (`list_scheduled`, `cancel_notification`)
+- **Campaign analytics** — Check delivery, open, click, bounce, and complaint metrics (`list_campaigns`, `get_campaign_stats`)
+- **Template management** — List, preview, and update templates with sample data (`list_templates`, `preview_template`, `upsert_template`)
+- **Users & preferences** — Look up users, contacts, preferences, and segment membership (`list_users`, `get_user_preferences`, `update_user_preferences`)
+- **Workflow operations** — Trigger workflows and inspect workflow runs (`create_workflow`, `trigger_workflow`, `get_workflow_run`)
+- **Suppressions & health** — Manage bounce suppressions, check system queues, and replay dead-letter messages (`list_suppressions`, `get_dead_letters`, `replay_dead_letter`)
+
+### From “write a script” to “just ask”
+
+| Without an agent | With NotifKit MCP |
+| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Jump into the DB to find contact info → open Twilio/Resend or write a throwaway script → format the payload → check their timezone manually → fire it off → hope it delivered | **You:** _“Send an urgent update to alex@acme.com that his package was lost in transit and support is rushing a replacement — text him if push doesn't deliver.”_
**Agent:** Looks up `alex@acme.com` → renders template → dispatches push with SMS fallback → bypasses quiet hours for urgent delivery → tracks delivery status → confirms it hit his phone |
+
+[Set up MCP](https://notifkit.dev/docs/mcp.html) · [MCP documentation](https://notifkit.dev/docs/mcp.html)
---
-## What you get
+## AI-assisted migration
-| The problem you don't want to build | How notifkit solves it |
-| :------------------------------------------------------- | :------------------------------------------------------------------- |
-| **“Should this user receive it?”** | User preferences, topic opt-outs, and consent gates |
-| **“Is this a bad time to send?”** | Timezone-aware quiet hours that defer non-urgent sends |
-| **“What if push fails?”** | Automatic ordered multi-channel fallback (`push` → `email` → `sms`) |
-| **“What if my worker crashes?”** | Redis Streams consumer groups, retries, and durable idempotency |
-| **“What if an event fires twice?”** | 24-hour deduplication via idempotency keys |
-| **“Can I send this later?”** | Priority scheduling with `sendAt` and cancellation before dispatch |
-| **“Can I send this 3 days after signup?”** | Stateful multi-step workflows with `wait` and `waitForEvent` |
-| **“How do I know what happened?”** | Queryable delivery logs, Prometheus metrics, and campaign reporting |
-| **“What happens when a provider goes down?”** | Circuit breakers, exponential backoff, and DLQ replay |
-| **“What about bounces and spam complaints?”** | RFC 8058 one-click unsubscribe and automatic hard-bounce suppression |
-| **“What if I don't want another SaaS holding my data?”** | 100% self-hosted on your PostgreSQL and Redis |
+Already have notification code scattered across your application?
-> **The idea is simple:** You decide what to say. **notifkit handles getting it there reliably.**
+Point your coding agent at:
----
+```text
+https://notifkit.dev/llms-full.txt
+```
-## What notifkit is — and what it isn't
-
-**What it is:** the durable notification infrastructure layer running directly inside your own stack.
-
-**What it isn't:** a marketing automation suite.
-
-notifkit is not Customer.io, OneSignal, or SendGrid. You bring your own provider accounts — your keys, your billing, your deliverability.
-
-First-party providers ship for Resend and Firebase Cloud Messaging. Anything else is a simple `Transport` class with a `send()` method.
+It can understand notifkit's API and help identify ad-hoc notification code in your repository and refactor it into durable notifkit calls.
---
@@ -239,57 +225,69 @@ curl -X POST http://localhost:3000/v1/notify \
---
-## Agent-operable
+## Battle-tested for production
-**notifkit isn't just an API your application can call — your AI agent can operate it directly.**
+> **Battle-tested in production:** notifkit powers production notification pipelines handling **thousands of emails, push notifications, and OTPs every day.**
+>
+> It is the infrastructure we built because we needed it ourselves — rather than spending months reinventing distributed notification plumbing or paying SaaS tolls per alert.
-Connect the notifkit MCP server ([`@notifkit/mcp`](./packages/mcp)) to Claude Code, Cursor, Claude Desktop, Gemini, or any MCP-compatible agent:
+**Your servers. Your providers. Your data. Zero notification SaaS markups.**
-```bash
-npx -y @notifkit/mcp
+### Reliability & Chaos Engineering
+
+Because notification delivery is mission-critical, every pipeline component is tested against extreme failure conditions:
+
+```mermaid
+flowchart LR
+ S1["Redis Streams"] -->|"Kill Worker (SIGKILL)"| M1["Auto-Claim and Replay"] --> O1["Zero Lost Messages"]
+ S2["Connection Loss"] -->|"Drop DB / Redis"| M2["Auto-Reconnect / Retry"] --> O2["In-Flight State Intact"]
+ S3["10k+ Messages"] -->|"Burst"| M3["Concurrency and Limits"] --> O3["Flat Memory, No Leaks"]
+
+ classDef fault stroke:#ef4444,stroke-width:2px
+ classDef guard stroke:#6366f1,stroke-width:2px
+ classDef result stroke:#22c55e,stroke-width:2px
+ class S1,S2,S3 fault
+ class M1,M2,M3 guard
+ class O1,O2,O3 result
```
-### Ask your agent
-
-```text
-You: Why didn't usr_9182 receive their password reset?
-
-Agent: The notification was suppressed because usr_9182's email
- address has a hard-bounce suppression from yesterday.
-```
-
-Your application and your AI agents use the **same notification infrastructure**:
-
-- **Send & dispatch** — Send one-off notifications or campaigns to users, lists, and segments (`send_notification`, `send_campaign`)
-- **Investigate & triage** — Diagnose delivery issues by inspecting message histories, provider responses, and quiet hours (`get_delivery_logs`, `get_notification`)
-- **Schedule & cancel** — Schedule future sends and cancel pending notifications (`list_scheduled`, `cancel_notification`)
-- **Campaign analytics** — Check delivery, open, click, bounce, and complaint metrics (`list_campaigns`, `get_campaign_stats`)
-- **Template management** — List, preview, and update templates with sample data (`list_templates`, `preview_template`, `upsert_template`)
-- **Users & preferences** — Look up users, contacts, preferences, and segment membership (`list_users`, `get_user_preferences`, `update_user_preferences`)
-- **Workflow operations** — Trigger workflows and inspect workflow runs (`create_workflow`, `trigger_workflow`, `get_workflow_run`)
-- **Suppressions & health** — Manage bounce suppressions, check system queues, and replay dead-letter messages (`list_suppressions`, `get_dead_letters`, `replay_dead_letter`)
-
-### From “write a script” to “just ask”
-
-| Without an agent | With NotifKit MCP |
-| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| Jump into the DB to find contact info → open Twilio/Resend or write a throwaway script → format the payload → check their timezone manually → fire it off → hope it delivered | **You:** _“Send an urgent update to alex@acme.com that his package was lost in transit and support is rushing a replacement — text him if push doesn't deliver.”_
**Agent:** Looks up `alex@acme.com` → renders template → dispatches push with SMS fallback → bypasses quiet hours for urgent delivery → tracks delivery status → confirms it hit his phone |
-
-[Set up MCP](https://notifkit.dev/docs/mcp.html) · [MCP documentation](https://notifkit.dev/docs/mcp.html)
+- **Chaos Monkey Testing (`tests/chaos/crash.test.ts`)**: Background worker processes are randomly terminated with `SIGKILL` during active, high-throughput message streaming. Consumer group Pending Entries List (PEL) re-claims guarantee **zero lost messages** and seamless failover.
+- **Infrastructure Recovery Testing (`tests/chaos/recovery.test.ts`)**: PostgreSQL and Redis connections are forcefully severed and restored under live traffic. Verifies automatic client reconnection, worker backpressure, and durable state resumption.
+- **High-Throughput Load Testing (`tests/chaos/load.test.ts`)**: Stressed with bursts of **10,000+ notifications** across parallel worker pools, verifying queue drain velocity, sliding-window rate limiters, and flat memory profiles without leaks.
+- **Race Conditions & Concurrency (`tests/race-conditions.test.ts`, `tests/idempotency.test.ts`)**: Hardened against concurrent duplicate dispatches, overlapping quiet-hour boundary evaluations, atomic user updates, and 24-hour idempotency key deduplication.
+- **100% Real Ephemeral Containers**: Unit, integration, and chaos test suites execute against real PostgreSQL and Redis containers via [Testcontainers](https://testcontainers.com), eliminating mocks for core storage and streaming primitives.
---
-## AI-assisted migration
+## What you get
-Already have notification code scattered across your application?
+| The problem you don't want to build | How notifkit solves it |
+| :------------------------------------------------------- | :------------------------------------------------------------------- |
+| **“Should this user receive it?”** | User preferences, topic opt-outs, and consent gates |
+| **“Is this a bad time to send?”** | Timezone-aware quiet hours that defer non-urgent sends |
+| **“What if push fails?”** | Automatic ordered multi-channel fallback (`push` → `email` → `sms`) |
+| **“What if my worker crashes?”** | Redis Streams consumer groups, retries, and durable idempotency |
+| **“What if an event fires twice?”** | 24-hour deduplication via idempotency keys |
+| **“Can I send this later?”** | Priority scheduling with `sendAt` and cancellation before dispatch |
+| **“Can I send this 3 days after signup?”** | Stateful multi-step workflows with `wait` and `waitForEvent` |
+| **“How do I know what happened?”** | Queryable delivery logs, Prometheus metrics, and campaign reporting |
+| **“What happens when a provider goes down?”** | Circuit breakers, exponential backoff, and DLQ replay |
+| **“What about bounces and spam complaints?”** | RFC 8058 one-click unsubscribe and automatic hard-bounce suppression |
+| **“What if I don't want another SaaS holding my data?”** | 100% self-hosted on your PostgreSQL and Redis |
-Point your coding agent at:
+> **The idea is simple:** You decide what to say. **notifkit handles getting it there reliably.**
-```text
-https://notifkit.dev/llms-full.txt
-```
+---
-It can understand notifkit's API and help identify ad-hoc notification code in your repository and refactor it into durable notifkit calls.
+## What notifkit is — and what it isn't
+
+**What it is:** the durable notification infrastructure layer running directly inside your own stack.
+
+**What it isn't:** a marketing automation suite.
+
+notifkit is not Customer.io, OneSignal, or SendGrid. You bring your own provider accounts — your keys, your billing, your deliverability.
+
+First-party providers ship for Resend and Firebase Cloud Messaging. Anything else is a simple `Transport` class with a `send()` method.
---
From 1993b2e99c5aa6e77017ba95edd67d355358b44d Mon Sep 17 00:00:00 2001
From: Suhaanth V V
Date: Wed, 2 Sep 2026 15:09:30 +0530
Subject: [PATCH 2/2] feat: add new providers
---
README.md | 2 +
drizzle/0002_wide_colleen_wing.sql | 2 +
drizzle/meta/0002_snapshot.json | 1460 +++++++++++++++++
drizzle/meta/_journal.json | 7 +
package-lock.json | 33 +
packages/mcp/README.md | 22 +-
packages/mcp/src/tools.ts | 33 +-
packages/provider-discord/LICENSE | 21 +
packages/provider-discord/README.md | 59 +
packages/provider-discord/package.json | 40 +
packages/provider-discord/src/index.ts | 115 ++
packages/provider-discord/tsconfig.json | 8 +
packages/provider-telegram/LICENSE | 21 +
packages/provider-telegram/README.md | 66 +
packages/provider-telegram/package.json | 40 +
packages/provider-telegram/src/index.ts | 115 ++
packages/provider-telegram/tsconfig.json | 8 +
src/contracts/common.ts | 10 +-
src/contracts/events/notification-enriched.ts | 2 +
src/contracts/sdk.ts | 11 +-
src/db/schema.ts | 10 +-
src/services/ai/main.ts | 10 +-
src/services/delivery/main.ts | 7 +-
src/services/enricher/main.ts | 2 +
24 files changed, 2071 insertions(+), 33 deletions(-)
create mode 100644 drizzle/0002_wide_colleen_wing.sql
create mode 100644 drizzle/meta/0002_snapshot.json
create mode 100644 packages/provider-discord/LICENSE
create mode 100644 packages/provider-discord/README.md
create mode 100644 packages/provider-discord/package.json
create mode 100644 packages/provider-discord/src/index.ts
create mode 100644 packages/provider-discord/tsconfig.json
create mode 100644 packages/provider-telegram/LICENSE
create mode 100644 packages/provider-telegram/README.md
create mode 100644 packages/provider-telegram/package.json
create mode 100644 packages/provider-telegram/src/index.ts
create mode 100644 packages/provider-telegram/tsconfig.json
diff --git a/README.md b/README.md
index 837c529..d70e637 100644
--- a/README.md
+++ b/README.md
@@ -320,6 +320,8 @@ First-party packages:
- [`@notifkit/provider-resend`](./packages/provider-resend) — transactional email via Resend
- [`@notifkit/provider-fcm`](./packages/provider-fcm) — push notifications via Firebase Cloud Messaging
+- [`@notifkit/provider-telegram`](./packages/provider-telegram) — messages via a Telegram bot
+- [`@notifkit/provider-discord`](./packages/provider-discord) — messages via a Discord webhook
For anything else, implement a simple `Transport`:
diff --git a/drizzle/0002_wide_colleen_wing.sql b/drizzle/0002_wide_colleen_wing.sql
new file mode 100644
index 0000000..1018a76
--- /dev/null
+++ b/drizzle/0002_wide_colleen_wing.sql
@@ -0,0 +1,2 @@
+ALTER TYPE "public"."channel" ADD VALUE 'telegram';--> statement-breakpoint
+ALTER TYPE "public"."channel" ADD VALUE 'discord';
\ No newline at end of file
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..112a4fe
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,1460 @@
+{
+ "id": "b2263eab-f6fb-46ed-863e-000e5a580f9d",
+ "prevId": "4d282198-10d4-4fbe-88e5-3df20c511678",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.contact_topic_preferences": {
+ "name": "contact_topic_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "topic": {
+ "name": "topic",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contact_topic_preferences_contact_id_user_contacts_id_fk": {
+ "name": "contact_topic_preferences_contact_id_user_contacts_id_fk",
+ "tableFrom": "contact_topic_preferences",
+ "tableTo": "user_contacts",
+ "columnsFrom": ["contact_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "contact_topic_preferences_contact_id_topic_unique": {
+ "name": "contact_topic_preferences_contact_id_topic_unique",
+ "nullsNotDistinct": false,
+ "columns": ["contact_id", "topic"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.delivery_outbox": {
+ "name": "delivery_outbox",
+ "schema": "",
+ "columns": {
+ "task_id": {
+ "name": "task_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "delivery_outbox_task_id_channel_destination_pk": {
+ "name": "delivery_outbox_task_id_channel_destination_pk",
+ "columns": ["task_id", "channel", "destination"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.message_logs": {
+ "name": "message_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'attempt'"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "campaign_id": {
+ "name": "campaign_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "message_logs_project_idx": {
+ "name": "message_logs_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "task_idx": {
+ "name": "task_idx",
+ "columns": [
+ {
+ "expression": "task_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "message_logs_project_task_idx": {
+ "name": "message_logs_project_task_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "task_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "provider_msg_idx": {
+ "name": "provider_msg_idx",
+ "columns": [
+ {
+ "expression": "provider_message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "msg_log_proj_time_idx": {
+ "name": "msg_log_proj_time_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "timestamp",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "msg_log_template_idx": {
+ "name": "msg_log_template_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "msg_log_workflow_idx": {
+ "name": "msg_log_workflow_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workflow_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "msg_log_campaign_idx": {
+ "name": "msg_log_campaign_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "campaign_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "task_channel_attempt_uidx": {
+ "name": "task_channel_attempt_uidx",
+ "nullsNotDistinct": false,
+ "columns": ["task_id", "channel", "attempt", "kind"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_api_keys": {
+ "name": "project_api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "api_key_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'admin'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_api_keys_project_id_projects_id_fk": {
+ "name": "project_api_keys_project_id_projects_id_fk",
+ "tableFrom": "project_api_keys",
+ "tableTo": "projects",
+ "columnsFrom": ["project_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "project_api_keys_key_hash_unique": {
+ "name": "project_api_keys_key_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key_hash"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.projects": {
+ "name": "projects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rate_limit_rpm": {
+ "name": "rate_limit_rpm",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "throttle_limit": {
+ "name": "throttle_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "throttle_window_hours": {
+ "name": "throttle_window_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.quiet_hours": {
+ "name": "quiet_hours",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "quiet_hours_user_idx": {
+ "name": "quiet_hours_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "quiet_hours_user_id_users_id_fk": {
+ "name": "quiet_hours_user_id_users_id_fk",
+ "tableFrom": "quiet_hours",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "quiet_hours_contact_id_user_contacts_id_fk": {
+ "name": "quiet_hours_contact_id_user_contacts_id_fk",
+ "tableFrom": "quiet_hours",
+ "tableTo": "user_contacts",
+ "columnsFrom": ["contact_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "check_owner": {
+ "name": "check_owner",
+ "value": "num_nonnulls(user_id, contact_id) = 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.scheduled_payloads": {
+ "name": "scheduled_payloads",
+ "schema": "",
+ "columns": {
+ "task_id": {
+ "name": "task_id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.suppressions": {
+ "name": "suppressions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target": {
+ "name": "target",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "suppression_project_idx": {
+ "name": "suppression_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "suppression_lookup_idx": {
+ "name": "suppression_lookup_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "channel",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "suppression_project_channel_target_uidx": {
+ "name": "suppression_project_channel_target_uidx",
+ "nullsNotDistinct": false,
+ "columns": ["project_id", "channel", "target"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.templates": {
+ "name": "templates",
+ "schema": "",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "topics": {
+ "name": "topics",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ai_prompts": {
+ "name": "ai_prompts",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "templates_project_id_projects_id_fk": {
+ "name": "templates_project_id_projects_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "projects",
+ "columnsFrom": ["project_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "templates_project_id_id_pk": {
+ "name": "templates_project_id_id_pk",
+ "columns": ["project_id", "id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_channel_preferences": {
+ "name": "user_channel_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_channel_preferences_user_id_users_id_fk": {
+ "name": "user_channel_preferences_user_id_users_id_fk",
+ "tableFrom": "user_channel_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_channel_preferences_user_id_channel_pk": {
+ "name": "user_channel_preferences_user_id_channel_pk",
+ "columns": ["user_id", "channel"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_contacts": {
+ "name": "user_contacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_primary": {
+ "name": "is_primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_contacts_user_id_users_id_fk": {
+ "name": "user_contacts_user_id_users_id_fk",
+ "tableFrom": "user_contacts",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_contacts_user_id_channel_target_unique": {
+ "name": "user_contacts_user_id_channel_target_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "channel", "target"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_segments": {
+ "name": "user_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment": {
+ "name": "segment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "segment_idx": {
+ "name": "segment_idx",
+ "columns": [
+ {
+ "expression": "segment",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_segments_user_id_users_id_fk": {
+ "name": "user_segments_user_id_users_id_fk",
+ "tableFrom": "user_segments",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_segments_user_id_segment_unique": {
+ "name": "user_segments_user_id_segment_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "segment"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_topic_preferences": {
+ "name": "user_topic_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "topic": {
+ "name": "topic",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_topic_preferences_user_id_users_id_fk": {
+ "name": "user_topic_preferences_user_id_users_id_fk",
+ "tableFrom": "user_topic_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_topic_preferences_user_id_topic_unique": {
+ "name": "user_topic_preferences_user_id_topic_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "topic"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attributes": {
+ "name": "attributes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_project_id_external_id_unique": {
+ "name": "users_project_id_external_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["project_id", "external_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_definitions": {
+ "name": "workflow_definitions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steps": {
+ "name": "steps",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workflow_definitions_project_id_name_unique": {
+ "name": "workflow_definitions_project_id_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["project_id", "name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_instances": {
+ "name": "workflow_instances",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "workflow_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "input": {
+ "name": "input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_name_idx": {
+ "name": "workflow_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_steps": {
+ "name": "workflow_steps",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_index": {
+ "name": "step_index",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "output": {
+ "name": "output",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "workflow_steps_instance_id_workflow_instances_id_fk": {
+ "name": "workflow_steps_instance_id_workflow_instances_id_fk",
+ "tableFrom": "workflow_steps",
+ "tableTo": "workflow_instances",
+ "columnsFrom": ["instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workflow_steps_instance_id_step_index_unique": {
+ "name": "workflow_steps_instance_id_step_index_unique",
+ "nullsNotDistinct": false,
+ "columns": ["instance_id", "step_index"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_waiters": {
+ "name": "workflow_waiters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_name": {
+ "name": "event_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_criteria": {
+ "name": "match_criteria",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "waiter_event_idx": {
+ "name": "waiter_event_idx",
+ "columns": [
+ {
+ "expression": "event_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "waiter_instance_idx": {
+ "name": "waiter_instance_idx",
+ "columns": [
+ {
+ "expression": "instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "waiter_comp_idx": {
+ "name": "waiter_comp_idx",
+ "columns": [
+ {
+ "expression": "event_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "waiter_match_idx": {
+ "name": "waiter_match_idx",
+ "columns": [
+ {
+ "expression": "match_criteria",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_waiters_instance_id_workflow_instances_id_fk": {
+ "name": "workflow_waiters_instance_id_workflow_instances_id_fk",
+ "tableFrom": "workflow_waiters",
+ "tableTo": "workflow_instances",
+ "columnsFrom": ["instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.api_key_role": {
+ "name": "api_key_role",
+ "schema": "public",
+ "values": ["admin", "read_only"]
+ },
+ "public.channel": {
+ "name": "channel",
+ "schema": "public",
+ "values": ["email", "sms", "push", "webhook", "in-app", "telegram", "discord"]
+ },
+ "public.workflow_status": {
+ "name": "workflow_status",
+ "schema": "public",
+ "values": ["pending", "running", "completed", "failed"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 6b688cb..f5b878d 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1786643990078,
"tag": "0001_stale_shotgun",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1788339340939,
+ "tag": "0002_wide_colleen_wing",
+ "breakpoints": true
}
]
}
diff --git a/package-lock.json b/package-lock.json
index d5d09ef..792b8c3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"": {
"name": "notifkit",
"version": "0.1.3",
+ "dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
@@ -1758,6 +1759,10 @@
"resolved": "packages/provider-console",
"link": true
},
+ "node_modules/@notifkit/provider-discord": {
+ "resolved": "packages/provider-discord",
+ "link": true
+ },
"node_modules/@notifkit/provider-fcm": {
"resolved": "packages/provider-fcm",
"link": true
@@ -1766,6 +1771,10 @@
"resolved": "packages/provider-resend",
"link": true
},
+ "node_modules/@notifkit/provider-telegram": {
+ "resolved": "packages/provider-telegram",
+ "link": true
+ },
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
@@ -12992,6 +13001,18 @@
"notifkit": "^0.1.0"
}
},
+ "packages/provider-discord": {
+ "name": "@notifkit/provider-discord",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "notifkit": "file:../../",
+ "typescript": "^5.8.3"
+ },
+ "peerDependencies": {
+ "notifkit": "^0.1.0"
+ }
+ },
"packages/provider-fcm": {
"name": "@notifkit/provider-fcm",
"version": "0.1.0",
@@ -13022,6 +13043,18 @@
"peerDependencies": {
"notifkit": "^0.1.0"
}
+ },
+ "packages/provider-telegram": {
+ "name": "@notifkit/provider-telegram",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "notifkit": "file:../../",
+ "typescript": "^5.8.3"
+ },
+ "peerDependencies": {
+ "notifkit": "^0.1.0"
+ }
}
}
}
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index 58f0682..d05a6e1 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -90,17 +90,17 @@ Because triple braces `{{{body}}}` preserve unescaped HTML, your agent in Claude
**Campaigns & Sending**
-| Tool | What it does |
-| --------------------- | --------------------------------------------------------------------------------------------------------- |
-| `send_campaign` | Send a template to a list of recipients across `email`, `sms`, `push`, or `webhook` with campaign tagging |
-| `send_notification` | Send to existing users, a segment, or a topic with `sendAt` scheduling and priority lanes |
-| `list_campaigns` | Recent campaign labels with size and last activity (supports `search`, `channel`, date range) |
-| `get_campaign_stats` | Sent, delivered, failed, opened, clicked, bounced, complained, unsubscribed, with rates |
-| `list_segments` | Every segment tag in use — the valid audiences to broadcast to |
-| `list_scheduled` | Sends queued for the future, including quiet-hours deferrals |
-| `get_delivery_logs` | Per-message detail with filters for template, channel, status, campaign, or user search |
-| `get_notification` | Status and delivery attempts for a specific task |
-| `cancel_notification` | Cancel a pending scheduled or quiet-hours deferred notification |
+| Tool | What it does |
+| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `send_campaign` | Send a template to a list of recipients across `email`, `sms`, `push`, `webhook`, `telegram`, or `discord` with campaign tagging |
+| `send_notification` | Send to existing users, a segment, or a topic with `sendAt` scheduling and priority lanes |
+| `list_campaigns` | Recent campaign labels with size and last activity (supports `search`, `channel`, date range) |
+| `get_campaign_stats` | Sent, delivered, failed, opened, clicked, bounced, complained, unsubscribed, with rates |
+| `list_segments` | Every segment tag in use — the valid audiences to broadcast to |
+| `list_scheduled` | Sends queued for the future, including quiet-hours deferrals |
+| `get_delivery_logs` | Per-message detail with filters for template, channel, status, campaign, or user search |
+| `get_notification` | Status and delivery attempts for a specific task |
+| `cancel_notification` | Cancel a pending scheduled or quiet-hours deferred notification |
**Templates** — `list_templates`, `get_template`, `upsert_template`, `delete_template`, `preview_template`, `render_template`
diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts
index fa84326..521f23d 100644
--- a/packages/mcp/src/tools.ts
+++ b/packages/mcp/src/tools.ts
@@ -3,8 +3,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { NotifkitApi } from "./client.js";
import { NotifkitApiError } from "./client.js";
-const CHANNELS = ["email", "sms", "push", "webhook", "in-app"] as const;
-const CONTACT_CHANNELS = ["email", "sms", "push", "webhook"] as const;
+const CHANNELS = ["email", "sms", "push", "webhook", "in-app", "telegram", "discord"] as const;
+const CONTACT_CHANNELS = ["email", "sms", "push", "webhook", "telegram", "discord"] as const;
const PRIORITIES = ["low", "normal", "high", "critical"] as const;
interface ToolResult {
@@ -209,14 +209,15 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
{
title: "Send a campaign to a list of addresses",
description:
- "Send one template to a list of recipient destinations (email, SMS/phone, push tokens, or webhook URLs) " +
- "given directly — the tool creates or updates a user record for each address, then sends, and " +
- "tags every message with a campaign label so the results can be read back later with get_campaign_stats. " +
- "Supports 'email', 'sms', 'push', and 'webhook' channels. Use this when the person supplies the " +
- "recipients themselves (a pasted list, a spreadsheet column). Always report the returned campaign label " +
- "back to the user — it is how they ask about results later. Suppressed addresses (previous " +
- "unsubscribes, complaints, hard bounces) are dropped automatically at send time, so the delivered " +
- "count is normally lower than the list size.",
+ "Send one template to a list of recipient destinations (email, SMS/phone, push tokens, webhook URLs, " +
+ "Telegram chat ids, or Discord webhook URLs) given directly — the tool creates or updates a user " +
+ "record for each address, then sends, and tags every message with a campaign label so the results " +
+ "can be read back later with get_campaign_stats. Supports 'email', 'sms', 'push', 'webhook', " +
+ "'telegram', and 'discord' channels. Use this when the person supplies the recipients themselves " +
+ "(a pasted list, a spreadsheet column). Always report the returned campaign label back to the user " +
+ "— it is how they ask about results later. Suppressed addresses (previous unsubscribes, complaints, " +
+ "hard bounces) are dropped automatically at send time, so the delivered count is normally lower than " +
+ "the list size.",
inputSchema: {
campaign: z
.string()
@@ -231,7 +232,7 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.enum(CONTACT_CHANNELS)
.optional()
.describe(
- "Channel to send on ('email', 'sms', 'push', or 'webhook'). Defaults to 'email'.",
+ "Channel to send on ('email', 'sms', 'push', 'webhook', 'telegram', or 'discord'). Defaults to 'email'.",
),
recipients: z
.array(z.string().min(1))
@@ -239,7 +240,8 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.max(10000)
.optional()
.describe(
- "Recipient destination addresses: email addresses for email, E.164 phone numbers for sms, device tokens for push, or URLs for webhook.",
+ "Recipient destination addresses: email addresses for email, E.164 phone numbers for sms, " +
+ "device tokens for push, URLs for webhook, chat ids for telegram, or webhook URLs for discord.",
),
emails: z
.array(z.string().email())
@@ -288,6 +290,10 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
userRecord = { id: `phone:${target}`, phone: target };
} else if (channel === "push") {
userRecord = { id: `push:${target}`, pushToken: target };
+ } else if (channel === "telegram") {
+ userRecord = { id: `telegram:${target}` };
+ } else if (channel === "discord") {
+ userRecord = { id: `discord:${target}` };
} else {
userRecord = { id: `webhook:${target}` };
}
@@ -830,7 +836,8 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.enum(CONTACT_CHANNELS)
.optional()
.describe(
- "Only list users who have a registered contact for this channel (email, sms, push, webhook).",
+ "Only list users who have a registered contact for this channel " +
+ "(email, sms, push, webhook, telegram, discord).",
),
},
annotations: { title: "List users", readOnlyHint: true },
diff --git a/packages/provider-discord/LICENSE b/packages/provider-discord/LICENSE
new file mode 100644
index 0000000..235464d
--- /dev/null
+++ b/packages/provider-discord/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 devkitshq
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/provider-discord/README.md b/packages/provider-discord/README.md
new file mode 100644
index 0000000..d1dd6d5
--- /dev/null
+++ b/packages/provider-discord/README.md
@@ -0,0 +1,59 @@
+# @notifkit/provider-discord
+
+Discord webhook transport for [notifkit](https://github.com/devkitshq/notifkit).
+Sends messages through an incoming Discord webhook and reports deleted/invalid
+webhooks back so notifkit can deactivate the contact.
+
+```bash
+npm install @notifkit/provider-discord
+```
+
+```ts
+import { NotifkitServer } from "notifkit";
+import { DiscordTransport } from "@notifkit/provider-discord";
+
+const server = new NotifkitServer({
+ services: ["all"],
+ providers: [new DiscordTransport()],
+});
+```
+
+## Getting a webhook URL
+
+In the target Discord channel: **Edit Channel → Integrations → Webhooks →
+New Webhook**, then copy its URL. Unlike email or push, the webhook URL _is_
+the destination — register it as the contact target directly:
+
+```ts
+await notifkit.addContact("usr_123", {
+ channel: "discord",
+ target: "https://discord.com/api/webhooks/123456789012345678/abcdef...",
+});
+```
+
+Then register a template on the `discord` channel and send:
+
+```ts
+await notifkit.syncTemplates({
+ templates: [
+ {
+ id: "order-shipped",
+ channel: "discord",
+ content: { text: "Order #{{orderId}} shipped!" },
+ },
+ ],
+});
+
+await notifkit.notify({
+ user: "usr_123",
+ template: "order-shipped",
+ channels: ["discord"],
+ data: { orderId: "9481" },
+});
+```
+
+A template's content may set `username` and `avatarUrl` to override the
+webhook's default identity per message, and `embeds` (an array of [Discord
+embed objects](https://discord.com/developers/docs/resources/webhook#execute-webhook))
+for richer formatting. Requires `notifkit` as a peer dependency.
+Documentation: [notifkit.dev/docs](https://notifkit.dev/docs/). MIT licensed.
diff --git a/packages/provider-discord/package.json b/packages/provider-discord/package.json
new file mode 100644
index 0000000..cd1e5b3
--- /dev/null
+++ b/packages/provider-discord/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@notifkit/provider-discord",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist",
+ "src",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "notifkit": "^0.1.0"
+ },
+ "devDependencies": {
+ "notifkit": "file:../../",
+ "typescript": "^5.8.3"
+ },
+ "description": "Discord webhook transport for notifkit — sends messages through an incoming Discord webhook.",
+ "license": "MIT",
+ "author": "devkitshq",
+ "homepage": "https://notifkit.dev",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/devkitshq/notifkit.git",
+ "directory": "packages/provider-discord"
+ },
+ "bugs": {
+ "url": "https://github.com/devkitshq/notifkit/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/provider-discord/src/index.ts b/packages/provider-discord/src/index.ts
new file mode 100644
index 0000000..f8fec1b
--- /dev/null
+++ b/packages/provider-discord/src/index.ts
@@ -0,0 +1,115 @@
+import type { DeliveryResult, Transport } from "notifkit";
+import type { NotificationDispatchedPayload, Logger } from "notifkit";
+
+export interface DiscordTransportOptions {
+ /**
+ * Default sender name shown on messages, overridable per template via
+ * `content.username`. Left unset, Discord uses the webhook's own name.
+ */
+ username?: string;
+ /** Default avatar URL, overridable per template via `content.avatarUrl`. */
+ avatarUrl?: string;
+ logger?: Logger;
+ /**
+ * Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten
+ * this if several templates share one webhook and could burst together.
+ */
+ limits?: { limit: number; windowSeconds: number };
+}
+
+export class DiscordTransport implements Transport {
+ readonly channel = "discord" as const;
+ readonly limits?: { limit: number; windowSeconds: number };
+
+ private readonly username?: string;
+ private readonly avatarUrl?: string;
+ private readonly logger?: Logger;
+
+ constructor({
+ username,
+ avatarUrl,
+ logger,
+ limits = { limit: 5, windowSeconds: 2 },
+ }: DiscordTransportOptions = {}) {
+ this.username = username;
+ this.avatarUrl = avatarUrl;
+ this.logger = logger;
+ this.limits = limits;
+ }
+
+ async send(task: NotificationDispatchedPayload): Promise {
+ // `destination` is optional on the contract, so a malformed task can reach
+ // a transport without one. Failing here costs one clear error; passing
+ // `undefined` as the webhook URL costs an opaque fetch failure instead.
+ const webhookUrl = task.destination;
+ if (!webhookUrl) {
+ return { success: false, error: "No destination (Discord webhook URL) on task" };
+ }
+
+ const content = task.renderedContent.content as any;
+ const body = content.text || content.body;
+ const subject = content.subject;
+ // Discord messages have no subject line of their own, so a template that
+ // renders one is bolded onto the first line rather than dropped.
+ const text = subject ? `**${subject}**\n${body}` : body;
+
+ // A template may carry its own sender, so one webhook can post as
+ // different bots for different alert types.
+ const username =
+ typeof content.username === "string" && content.username ? content.username : this.username;
+ const avatarUrl =
+ typeof content.avatarUrl === "string" && content.avatarUrl
+ ? content.avatarUrl
+ : this.avatarUrl;
+ const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;
+
+ try {
+ // `wait=true` makes Discord return the created message (with its id)
+ // in the response body instead of an empty 204 — without it there is
+ // no providerMessageId to record in the delivery log.
+ const url = new URL(webhookUrl);
+ url.searchParams.set("wait", "true");
+
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ content: text,
+ ...(username ? { username } : {}),
+ ...(avatarUrl ? { avatar_url: avatarUrl } : {}),
+ ...(embeds ? { embeds } : {}),
+ }),
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.text();
+ // A deleted or regenerated webhook answers 404/401 for every future
+ // request — the target itself is dead, not a transient failure, so
+ // the caller should deactivate the contact rather than retry it.
+ const invalidToken = res.status === 404 || res.status === 401;
+
+ this.logger?.warn(
+ { taskId: task.taskId, status: res.status, errorBody },
+ "Discord send failed",
+ );
+
+ return {
+ success: false,
+ invalidToken,
+ error: `Discord webhook error ${res.status}: ${errorBody}`,
+ };
+ }
+
+ const data = (await res.json().catch(() => null)) as { id?: string } | null;
+ const providerMessageId = data?.id ?? "";
+
+ this.logger?.debug({ taskId: task.taskId, providerMessageId }, "Discord message sent");
+ return { success: true, providerMessageId };
+ } catch (err) {
+ const error = err as Error;
+ this.logger?.error({ taskId: task.taskId, error: error.message }, "Discord unexpected error");
+
+ return { success: false, error: error.message };
+ }
+ }
+}
diff --git a/packages/provider-discord/tsconfig.json b/packages/provider-discord/tsconfig.json
new file mode 100644
index 0000000..a013e0c
--- /dev/null
+++ b/packages/provider-discord/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/provider-telegram/LICENSE b/packages/provider-telegram/LICENSE
new file mode 100644
index 0000000..235464d
--- /dev/null
+++ b/packages/provider-telegram/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 devkitshq
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/provider-telegram/README.md b/packages/provider-telegram/README.md
new file mode 100644
index 0000000..54fc64f
--- /dev/null
+++ b/packages/provider-telegram/README.md
@@ -0,0 +1,66 @@
+# @notifkit/provider-telegram
+
+Telegram Bot API transport for [notifkit](https://github.com/devkitshq/notifkit).
+Sends messages through a Telegram bot to a chat id, and reports blocked/kicked
+bots back so notifkit can deactivate the contact.
+
+```bash
+npm install @notifkit/provider-telegram
+```
+
+```ts
+import { NotifkitServer } from "notifkit";
+import { TelegramTransport } from "@notifkit/provider-telegram";
+
+const server = new NotifkitServer({
+ services: ["all"],
+ providers: [
+ new TelegramTransport({
+ botToken: process.env.TELEGRAM_BOT_TOKEN!,
+ }),
+ ],
+});
+```
+
+## Getting a bot token
+
+Message [@BotFather](https://t.me/BotFather) on Telegram, run `/newbot`, and
+copy the token it gives you.
+
+## Getting a chat id
+
+A user must start a conversation with your bot before it can message them —
+Telegram has no way to message someone who hasn't. Once they've sent `/start`,
+read their chat id from `getUpdates` (or a webhook on your own bot) and
+register it as their contact:
+
+```ts
+await notifkit.addContact("usr_123", { channel: "telegram", target: "5910738042" });
+```
+
+Then register a template on the `telegram` channel and send:
+
+```ts
+await notifkit.syncTemplates({
+ templates: [
+ {
+ id: "order-shipped",
+ channel: "telegram",
+ content: { text: "Your order #{{orderId}} shipped!" },
+ },
+ ],
+});
+
+await notifkit.notify({
+ user: "usr_123",
+ template: "order-shipped",
+ channels: ["telegram"],
+ data: { orderId: "9481" },
+});
+```
+
+A template's content may set `parseMode` (`"HTML"`, `"MarkdownV2"`, or
+`"Markdown"`) to opt into formatted messages, `disableWebPagePreview` to
+suppress link previews, and `disableNotification` for a silent send.
+Requires `notifkit` as a peer dependency.
+Documentation: [notifkit.dev/docs](https://notifkit.dev/docs/). MIT licensed.
diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json
new file mode 100644
index 0000000..6bc99fc
--- /dev/null
+++ b/packages/provider-telegram/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@notifkit/provider-telegram",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist",
+ "src",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "notifkit": "^0.1.0"
+ },
+ "devDependencies": {
+ "notifkit": "file:../../",
+ "typescript": "^5.8.3"
+ },
+ "description": "Telegram Bot API transport for notifkit — sends messages through a Telegram bot.",
+ "license": "MIT",
+ "author": "devkitshq",
+ "homepage": "https://notifkit.dev",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/devkitshq/notifkit.git",
+ "directory": "packages/provider-telegram"
+ },
+ "bugs": {
+ "url": "https://github.com/devkitshq/notifkit/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/provider-telegram/src/index.ts b/packages/provider-telegram/src/index.ts
new file mode 100644
index 0000000..2d94486
--- /dev/null
+++ b/packages/provider-telegram/src/index.ts
@@ -0,0 +1,115 @@
+import type { DeliveryResult, Transport } from "notifkit";
+import type { NotificationDispatchedPayload, Logger } from "notifkit";
+
+export interface TelegramTransportOptions {
+ /** Bot token from @BotFather, e.g. "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11". */
+ botToken: string;
+ /**
+ * Formatting mode applied to every message unless a template's content
+ * names its own `parseMode`. Left unset, Telegram renders plain text —
+ * the safe default, since HTML/MarkdownV2 both reject malformed markup
+ * with a 400 rather than falling back to plain text.
+ */
+ parseMode?: "HTML" | "MarkdownV2" | "Markdown";
+ logger?: Logger;
+ /**
+ * Telegram enforces ~30 messages/sec bot-wide and 1/sec per chat. The
+ * default throttles to the bot-wide ceiling; tighten it if most sends
+ * land in a handful of chats.
+ */
+ limits?: { limit: number; windowSeconds: number };
+}
+
+export class TelegramTransport implements Transport {
+ readonly channel = "telegram" as const;
+ readonly limits?: { limit: number; windowSeconds: number };
+
+ private readonly botToken: string;
+ private readonly parseMode?: string;
+ private readonly logger?: Logger;
+
+ constructor({
+ botToken,
+ parseMode,
+ logger,
+ limits = { limit: 30, windowSeconds: 1 },
+ }: TelegramTransportOptions) {
+ this.botToken = botToken;
+ this.parseMode = parseMode;
+ this.logger = logger;
+ this.limits = limits;
+ }
+
+ async send(task: NotificationDispatchedPayload): Promise {
+ // `destination` is optional on the contract, so a malformed task can reach
+ // a transport without one. Failing here costs one clear error; passing
+ // `undefined` to the Bot API costs an opaque provider rejection and a
+ // retry cycle.
+ const chatId = task.destination;
+ if (!chatId) {
+ return { success: false, error: "No destination (Telegram chat id) on task" };
+ }
+
+ const content = task.renderedContent.content as any;
+ const body = content.text || content.body;
+ const subject = content.subject;
+ // Telegram messages have no subject line of their own, so a template
+ // that renders one gets folded into the first line rather than dropped.
+ const text = subject ? `${subject}\n\n${body}` : body;
+ const parseMode = typeof content.parseMode === "string" ? content.parseMode : this.parseMode;
+
+ try {
+ const res = await fetch(`https://api.telegram.org/bot${this.botToken}/sendMessage`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ chat_id: chatId,
+ text,
+ ...(parseMode ? { parse_mode: parseMode } : {}),
+ ...(content.disableWebPagePreview ? { link_preview_options: { is_disabled: true } } : {}),
+ ...(content.disableNotification ? { disable_notification: true } : {}),
+ }),
+ });
+
+ const data = (await res.json()) as {
+ ok: boolean;
+ result?: { message_id: number };
+ error_code?: number;
+ description?: string;
+ };
+
+ if (!data.ok) {
+ // The bot was blocked/kicked, or the chat id no longer resolves to
+ // anything — the target itself is dead, not a transient failure, so
+ // the caller should deactivate the contact rather than retry it.
+ const invalidToken =
+ data.error_code === 403 ||
+ (data.error_code === 400 &&
+ /chat not found|user is deactivated/i.test(data.description ?? ""));
+
+ this.logger?.warn(
+ { taskId: task.taskId, errorCode: data.error_code, description: data.description },
+ "Telegram send failed",
+ );
+
+ return {
+ success: false,
+ invalidToken,
+ error: data.description ?? `Telegram API error ${data.error_code ?? "unknown"}`,
+ };
+ }
+
+ const providerMessageId = String(data.result?.message_id ?? "");
+ this.logger?.debug({ taskId: task.taskId, providerMessageId }, "Telegram message sent");
+ return { success: true, providerMessageId };
+ } catch (err) {
+ const error = err as Error;
+ this.logger?.error(
+ { taskId: task.taskId, error: error.message },
+ "Telegram unexpected error",
+ );
+
+ return { success: false, error: error.message };
+ }
+ }
+}
diff --git a/packages/provider-telegram/tsconfig.json b/packages/provider-telegram/tsconfig.json
new file mode 100644
index 0000000..a013e0c
--- /dev/null
+++ b/packages/provider-telegram/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/src/contracts/common.ts b/src/contracts/common.ts
index 8e18933..6086188 100644
--- a/src/contracts/common.ts
+++ b/src/contracts/common.ts
@@ -1,6 +1,14 @@
import { z } from "zod";
-export const NotificationChannelSchema = z.enum(["email", "sms", "push", "webhook", "in-app"]);
+export const NotificationChannelSchema = z.enum([
+ "email",
+ "sms",
+ "push",
+ "webhook",
+ "in-app",
+ "telegram",
+ "discord",
+]);
export type NotificationChannel = z.infer;
export const NotificationPrioritySchema = z.enum(["low", "normal", "high", "critical"]);
diff --git a/src/contracts/events/notification-enriched.ts b/src/contracts/events/notification-enriched.ts
index 011b2e4..0ad3bc2 100644
--- a/src/contracts/events/notification-enriched.ts
+++ b/src/contracts/events/notification-enriched.ts
@@ -8,6 +8,8 @@ export const RecipientProfileSchema = z.object({
webhook: z.string().url().optional(),
pushTokens: z.array(z.string()).optional(),
pushToken: z.string().optional(),
+ telegram: z.string().optional(),
+ discord: z.string().url().optional(),
locale: z.string().default("en"),
timezone: z.string().default("UTC"),
preferences: z.object({
diff --git a/src/contracts/sdk.ts b/src/contracts/sdk.ts
index c7178f9..db72e9a 100644
--- a/src/contracts/sdk.ts
+++ b/src/contracts/sdk.ts
@@ -22,8 +22,15 @@ export type Preferences = z.infer;
// ─── Contacts ─────────────────────────────────────────────────────────────────
-/** Channels that carry an addressable target (email address, phone, push token, url). */
-export const ContactChannelSchema = z.enum(["email", "sms", "push", "webhook"]);
+/** Channels that carry an addressable target (email address, phone, push token, url, chat id). */
+export const ContactChannelSchema = z.enum([
+ "email",
+ "sms",
+ "push",
+ "webhook",
+ "telegram",
+ "discord",
+]);
export type ContactChannel = z.infer;
/** Accept a single value or an array; always normalise to a non-empty array. */
diff --git a/src/db/schema.ts b/src/db/schema.ts
index c24a39c..573899c 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -17,7 +17,15 @@ import {
import { sql } from "drizzle-orm";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
-export const channelEnum = pgEnum("channel", ["email", "sms", "push", "webhook", "in-app"]);
+export const channelEnum = pgEnum("channel", [
+ "email",
+ "sms",
+ "push",
+ "webhook",
+ "in-app",
+ "telegram",
+ "discord",
+]);
export const projects = pgTable("projects", {
id: uuid("id").primaryKey().defaultRandom(),
diff --git a/src/services/ai/main.ts b/src/services/ai/main.ts
index e830897..8c6d271 100644
--- a/src/services/ai/main.ts
+++ b/src/services/ai/main.ts
@@ -168,9 +168,13 @@ export class AiWorker extends BaseWorker {
? pending.recipient.phone
: pending.channel === "webhook"
? pending.recipient.webhook
- : pending.channel === "push"
- ? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)
- : undefined;
+ : pending.channel === "telegram"
+ ? pending.recipient.telegram
+ : pending.channel === "discord"
+ ? pending.recipient.discord
+ : pending.channel === "push"
+ ? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)
+ : undefined;
const resolvedDestination =
destination ?? (pending.channel === "push" ? undefined : pending.recipientId);
diff --git a/src/services/delivery/main.ts b/src/services/delivery/main.ts
index 77cdac4..c3dbcba 100644
--- a/src/services/delivery/main.ts
+++ b/src/services/delivery/main.ts
@@ -422,10 +422,13 @@ export class DeliveryWorker extends BaseWorker {
await this.contactRepo.deactivate(
task.projectId,
task.recipientId,
- "push",
+ task.channel as any,
task.destination,
);
- this.logger.info({ token: task.destination }, "deactivated invalid push token");
+ this.logger.info(
+ { channel: task.channel, target: task.destination },
+ "deactivated invalid contact target",
+ );
this.globalEmitter.emit(
"delivery:failed",
task.taskId,
diff --git a/src/services/enricher/main.ts b/src/services/enricher/main.ts
index f27e234..56e3960 100644
--- a/src/services/enricher/main.ts
+++ b/src/services/enricher/main.ts
@@ -429,6 +429,8 @@ export class EnricherWorker extends BaseWorker {
: (profile.email ?? undefined),
phone: channel === "sms" ? destination : undefined,
webhook: channel === "webhook" ? destination : undefined,
+ telegram: channel === "telegram" ? destination : undefined,
+ discord: channel === "discord" ? destination : undefined,
locale: profile.language ?? "en",
timezone: profile.timezone ?? "UTC",
preferences: {