Self-hosted notification server. One Rust binary, Postgres only. Email, SMS, WhatsApp, push, in-app inbox. Operated by your agent over MCP. Open source, MIT.
Base URL: http://localhost:3400 (dev) or your deployed domain.
All endpoints require X-Api-Key: sk_<project>_xxx header unless noted otherwise.
Send your first notification in 30 seconds:
curl -X POST http://localhost:3400/v1/send \
-H "X-Api-Key: sk_myapp_xxx" \
-H "Content-Type: application/json" \
-d '{
"channels": ["email"],
"subscriber_id": "user-1",
"subject": "Hello!",
"body": "Welcome to the app."
}'
X-Api-Key: sk_myapp_xxxxxxxxxxxxxxxxxxxxx
Defined per project in notifyd.toml. Identifies the project and scopes all data.
For inbox/SSE endpoints, generate a short-lived token:
curl -X POST http://localhost:3400/v1/auth/subscriber-token \
-H "X-Api-Key: sk_myapp_xxx" \
-d '{"subscriber_id": "user-1"}'
# → {"token": "eyJ..."}
Then use it:
Authorization: Bearer eyJ...
Some endpoints (/v1/metrics, /v1/admin/*) require the admin API key defined in config.
All responses are JSON.
{"success": true, "id": "uuid", ...}
{"error": "Description of what went wrong"}
| Status | When |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Invalid request body |
| 401 | Missing or invalid API key |
| 403 | Forbidden (wrong subscriber, wrong project) |
| 404 | Resource not found |
| 422 | Invalid field, or a channel this instance has no connector for (channel_not_configured) |
| 429 | Rate limited |
| 500 | Internal server error |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /v1/send |
API Key | Send notification (immediate or scheduled) |
| POST | /v1/batch |
API Key | Send to multiple subscribers |
| GET | /v1/channels |
API Key | Channels this project may send and this instance can deliver |
| GET | /v1/jobs/:id |
API Key | Get job status |
| DELETE | /v1/jobs/:id |
API Key | Cancel pending/scheduled job |
| POST | /v1/subscribers |
API Key | Create or update subscriber |
| GET | /v1/subscribers |
API Key | List subscribers |
| GET | /v1/subscribers/:id |
API Key | Get subscriber |
| DELETE | /v1/subscribers/:id |
API Key | Delete subscriber |
| GET | /v1/subscribers/:id/preferences |
API Key | Get notification preferences |
| PUT | /v1/subscribers/:id/preferences |
API Key | Set notification preferences |
| GET | /v1/inbox/:sub_id |
JWT or API Key | List in-app notifications |
| PATCH | /v1/inbox/:sub_id/:msg_id |
JWT or API Key | Update notification (read/archive/todo) |
| POST | /v1/inbox/:sub_id/read-all |
JWT or API Key | Mark all as read |
| GET | /v1/inbox/:sub_id/unread-count |
JWT or API Key | Unread badge count |
| GET | /v1/inbox/:sub_id/stream |
Ticket (query), or JWT / API Key (header) | SSE realtime stream |
| POST | /v1/inbox/:sub_id/stream-ticket |
JWT or API Key | One-time SSE auth ticket |
| POST | /v1/auth/subscriber-token |
API Key | Generate subscriber JWT |
| POST | /v1/workflows |
API Key | Create/update workflow |
| GET | /v1/workflows |
API Key | List workflows |
| GET | /v1/workflows/:id |
API Key | Get workflow |
| DELETE | /v1/workflows/:id |
API Key | Delete workflow |
| POST | /v1/workflows/trigger |
API Key | Trigger workflow event |
| GET | /v1/workflows/runs |
API Key | List workflow runs |
| DELETE | /v1/workflows/runs/:id |
API Key | Cancel workflow run |
| POST | /v1/templates |
API Key | Create/update template |
| GET | /v1/templates |
API Key | List templates |
| GET | /v1/templates/:id |
API Key | Get template |
| DELETE | /v1/templates/:id |
API Key | Delete template |
| POST | /v1/push-tokens |
API Key | Register push token |
| GET | /v1/push-tokens/subscriber/:id |
API Key | List push tokens |
| DELETE | /v1/push-tokens/:id |
API Key | Delete push token |
| GET | /v1/health |
None | Health check |
| GET | /v1/metrics |
Admin | Service metrics |
| POST | /v1/admin/projects |
Admin | Create project |
| GET | /v1/admin/projects |
Admin | List projects |
| POST | /v1/admin/projects/:id/rotate-key |
Admin | Rotate API key |
| POST | /v1/admin/projects/:id/revoke-secondary |
Admin | Revoke old key |
| DELETE | /v1/admin/projects/:id |
Admin | Delete project |
| GET | /v1/admin/audit |
Admin | Audit log |
| POST | /v1/admin/webhooks |
Admin | Create webhook |
| GET | /v1/admin/webhooks |
Admin | List webhooks |
| DELETE | /v1/admin/webhooks/:id |
Admin | Delete webhook |
Send a notification via one or more channels. Jobs are queued and processed asynchronously (<1s typical).
Request:
{
"channels": ["email", "in_app"],
"subscriber_id": "user-uuid",
"to": "user@example.com",
"cc": ["orders@example.com"],
"reply_to": "buyer@example.com",
"subject": "Your order shipped",
"body": "Hey , order # is on its way!",
"vars": {
"first_name": "Alice",
"order_id": "ORD-42"
},
"scheduled_at": "2026-03-25T14:00:00Z",
"idempotency_key": "order-42-shipped"
}
| Field | Type | Required | Description |
|---|---|---|---|
channels |
string[] |
✅ (or channel) |
"email", "sms", "in_app", "push" |
channel |
string |
✅ (or channels) |
Single channel shorthand |
subscriber_id |
string |
❌ | Links to subscriber record for template vars |
to |
string |
❌ | Override recipient (email/phone). Falls back to subscriber. |
subject |
string |
❌ | Email subject |
body |
string |
❌ | Message body (supports `` substitution) |
template |
string |
❌ | Use a stored template instead of inline body |
vars |
object |
❌ | Template variables |
scheduled_at |
ISO 8601 |
❌ | Schedule for future delivery (default: now) |
idempotency_key |
string |
❌ | Dedupes sends: reusing a key held by a live or succeeded job returns that job untouched (no re-send); a failed/cancelled job releases its key, so retrying after a failure creates a fresh job |
attachments |
object[] |
❌ | Email only. [{ "filename", "content" (base64), "content_type"? }]. Forces single-send (Resend batch rejects attachments). The whole request body is capped at 5 MB (413 beyond), so keep attachments under ~3.5 MB once base64-encoded. |
cc |
string[] |
❌ | Email only. Up to 10 carbon-copy recipients; duplicates are removed. |
reply_to |
string |
❌ | Email only. Address that receives replies. |
from_name |
string |
❌ | Email only. Sender display name for this email (1–100 characters, no control characters nor " < > \). The address stays the project’s from_email (or the instance’s): only its domain is verified. |
from_local_part |
string |
❌ | Email only. Local part of the sender address for this email: cdmf sends from cdmf@<domain of the project's from_email> (1–64 ASCII letters, digits, . _ -, starting and ending with a letter or digit, no ..). The domain never changes; ignored when the project has no from_email. |
priority |
string \| int |
❌ | Queue lane: critical (10), high (30), normal (50, default), low (70), bulk (80) or 0–100. Lower goes first. An email tagged {"name":"category","value":"campaign"\|"marketing"\|"newsletter"} defaults to bulk. |
tags |
object[] |
❌ | Email only. Provider tags [{ "name", "value" }]; also drives the default priority (see above). |
email_headers |
object |
❌ | Email only. Custom MIME headers such as List-Unsubscribe. |
send_window |
object \| false |
❌ | { "start": "09:00", "end": "20:00", "tz": "Europe/Paris", "days": [1..7], "applies_to": "marketing" \| "all" }. Bulk email waits for the recipient’s daytime (subscribers.timezone, else tz). Overrides the project’s settings.send_window; false bypasses it. |
sms |
object |
❌ | { "from" } replaces the instance’s SMS_FROM for this message: an E.164 number, or an alphanumeric sender of 1 to 11 letters, digits or spaces with at least one letter (one-way, the recipient cannot reply). Checked on every request, whatever the channels: anything else is refused with 422. |
transactional |
bool |
❌ | Marks a message the subscriber cannot decline: password reset, magic link, email verification, order receipt, security alert. Subscription preferences (topic, workflow, channel and global opt-outs) are not consulted, at enqueue and at send, so an opt-out can never lock somebody out of their own account. Suppressions still apply: a hard bounce or a spam complaint keeps blocking the address, because that is a deliverability signal and not a choice. Never set it on marketing — /v1/batch rejects the field with 422. |
Response:
{
"success": true,
"job_ids": ["uuid", "uuid"],
"scheduled_at": "2026-03-25T14:00:00Z",
"channels": ["email", "in_app"],
"topic": null,
"skipped": [{"channel": "sms", "reason": "sms connector not configured"}]
}
skipped lists every requested channel that got no job, with the reason: no
address for it, an opt-out, or no way to deliver it on this instance (no
connector, or an alphanumeric SMS sender on Telnyx without
TELNYX_MESSAGING_PROFILE_ID). Such a channel is refused at enqueue instead
of queued to fail later. When no job is created and at least one channel was
refused that way, the answer is 422:
{
"error": "channel_not_configured",
"message": "sms connector not configured",
"skipped": [{"channel": "sms", "reason": "sms connector not configured"}]
}
A 2xx with an empty job_ids means nothing was queued: every channel was
skipped for an address or an opt-out.
Returns both the transport state and the provider evidence. status: "sent"
means the provider accepted the API call; delivered_at and the per-recipient
provider_events are the proof of what happened afterwards. On a Twilio or
smsmode SMS, delivered_at, bounced_at and the bounced status come from the
provider’s status callbacks (see SMS Delivery Status below); SMS jobs list no
provider_events.
{
"id": "uuid",
"status": "sent",
"priority": 50,
"attempts": 1,
"max_attempts": 5,
"provider": "resend",
"provider_message_id": "provider-email-id",
"sent_at": "2026-08-14T08:16:10Z",
"delivered_at": "2026-08-14T08:16:14Z",
"bounced_at": null,
"email_envelope": {
"to": ["supplier@example.com"],
"cc": ["orders@example.com"],
"reply_to": "orders@example.com"
},
"provider_events": [
{
"provider": "resend",
"type": "email.delivered",
"occurred_at": "2026-08-14T08:16:14Z",
"provider_message_id": "provider-email-id",
"recipients": ["user@example.com"],
"error": null
}
]
}
provider and provider_message_id name the connector that accepted the
message and the provider’s own identifier (Resend id, Telnyx message id,
Twilio message SID, SMTP Message-ID).
Job lifecycle. pending → processing → sent | retry | failed.
The worker classifies every provider answer:
| Provider answer | Job | Notes |
|---|---|---|
| accepted | sent |
provider_message_id stored |
429, 5xx, network — with EMAIL_FALLBACK_PROVIDER set |
sent through the fallback at once | the primary rests for EMAIL_FAILOVER_COOLDOWN_SECS; provider on the job names the connector that accepted |
429 |
retry, attempt not consumed |
the channel lane pauses for Retry-After (default RATE_LIMIT_PAUSE_SECS) |
5xx, timeout, network |
retry |
backoff 30 s → 2 min → 10 min → 30 min → 2 h (±20 % jitter), failed after max_attempts (default 5) |
other 4xx, invalid recipient, unverified sender, integrity violation |
failed at once |
retrying the same request would give the same answer |
| recipient on the suppression list | failed at once |
the provider is never contacted |
A job left in processing for more than 10 minutes (worker crash, OOM, hard
restart) is re-queued by the reaper with its attempt consumed, so nothing is
lost silently and nothing loops forever.
After sent, provider feedback can still stamp delivered_at or turn the job
bounced: Resend webhooks for email (Email Deliverability), Twilio and smsmode
status callbacks for SMS (SMS Delivery Status).
email_envelope is the exact recipient envelope persisted before provider
handoff. It lets clients distinguish a recipient still awaiting an event from
an address that was never included in the accepted send. It is null for
non-email jobs.
curl example:
curl -X POST http://localhost:3400/v1/send \
-H "X-Api-Key: sk_myapp_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"subscriber_id": "user-1",
"subject": "Password reset",
"body": "Click here to reset: ",
"vars": {"reset_url": "https://app.example.com/reset?token=abc"}
}'
TypeScript example:
const response = await fetch('https://notifyd.example.com/v1/send', {
method: 'POST',
headers: {
'X-Api-Key': process.env.NOTIFYD_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
channels: ['email', 'in_app'],
subscriber_id: userId,
subject: 'New comment on your post',
body: ' commented: ""',
vars: { commenter: 'Bob', comment: 'Great post!' },
}),
});
Rust example:
let client = reqwest::Client::new();
let res = client
.post("http://localhost:3400/v1/send")
.header("X-Api-Key", "sk_myapp_xxx")
.json(&serde_json::json!({
"channel": "email",
"subscriber_id": "user-1",
"subject": "Welcome!",
"body": "Hello !",
"vars": {"first_name": "Alice"}
}))
.send()
.await?;
Fan-out to many subscribers. Jobs default to priority bulk (80): a campaign
never delays transactional traffic. Pass "priority" to override.
send_window works as on /v1/send; each recipient is scheduled in its own
timezone. idempotency_key (optional) dedupes the whole fan-out: the key is declined per
subscriber and channel, so replaying the same call after a timeout creates no
second job for anyone already queued or sent. The response reports
jobs_created and jobs_deduplicated.
transactional is rejected here with 422: a fan-out is marketing by
definition, and the flag disables the opt-out check. Undeclinable messages go
out one by one through /v1/send.
A channel this instance cannot deliver (see /v1/send) fails the whole batch
with 422 channel_not_configured before any job is created.
Send the same notification to multiple subscribers.
curl -X POST http://localhost:3400/v1/batch \
-H "X-Api-Key: sk_myapp_xxx" \
-H "Content-Type: application/json" \
-d '{
"channels": ["email", "in_app"],
"subscribers": ["user-1", "user-2", "user-3"],
"template": "weekly_digest",
"vars": {"week": "March 24-30"},
"icon": "calendar",
"url": "/digest/2026-w13"
}'
icon and url are optional and are forwarded to in-app notifications for batch sends too.
What the calling project can send on this instance. allowed means the channel
is in the project’s channel list (informational: /v1/send does not enforce
it); configured means this instance can deliver it with its default sender,
so /v1/send will not answer 422 channel_not_configured for it. Offer a
channel to users only when both are true.
curl http://localhost:3400/v1/channels -H "X-Api-Key: sk_myapp_xxx"
{
"channels": {
"email": {"allowed": true, "configured": true},
"sms": {"allowed": true, "configured": false},
"whatsapp": {"allowed": false, "configured": false},
"push": {"allowed": true, "configured": true},
"in_app": {"allowed": true, "configured": true}
}
}
List in-app notifications for a subscriber.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
limit |
int |
20 | Max items to return |
cursor |
string |
— | Pagination cursor (ISO timestamp) |
unread_only |
bool |
false | Filter to unread only |
Response:
{
"notifications": [
{
"id": "uuid",
"body": "New comment on your post",
"icon": "message",
"url": "/posts/42#comments",
"read_at": null,
"is_todo": false,
"created_at": "2026-03-25T10:30:00Z"
}
],
"has_more": true,
"next_cursor": "2026-03-25T10:29:00Z"
}
Realtime SSE stream for in-app notifications.
Auth: a browser EventSource cannot send headers, so the URL carries a
one-time ticket and nothing else: ?ticket=<ticket> from
POST /v1/inbox/:sub_id/stream-ticket (valid 30 seconds, consumed by the first
connection, bound to the project and the subscriber of the path). A used,
expired or unknown ticket is 401; a ticket for another subscriber is 403. A
subscriber JWT or an API key is never accepted in the URL, where proxy logs,
browser history and Referer keep it: send it as Authorization: Bearer
(non-browser clients streaming with fetch). ?token= is the former name of
ticket, still read as a ticket only; use ticket.
Each reconnection needs a new ticket: on error, close the EventSource and
open a new one with a fresh ticket instead of letting the browser retry the
consumed URL.
Events:
event: message
data: {"type":"new_notification","notification":{"id":"uuid","body":"...","icon":"bell","created_at":"..."}}
event: message
data: {"type":"count_update","unread_count":5}
event: message
data: {"type":"read","notification_id":"uuid"}
event: message
data: {"type":"archived","notification_id":"uuid"}
JavaScript example:
// The ticket request carries the subscriber JWT in a header (or is made by
// your server with the project API key); only the ticket goes in the URL.
const { ticket } = await fetch(
`https://notifyd.example.com/v1/inbox/${userId}/stream-ticket`,
{ method: 'POST', headers: { Authorization: `Bearer ${jwt}` } },
).then((r) => r.json());
const events = new EventSource(
`https://notifyd.example.com/v1/inbox/${userId}/stream?ticket=${encodeURIComponent(ticket)}`
);
events.onmessage = (e) => {
const data = JSON.parse(e.data);
switch (data.type) {
case 'new_notification':
showToast(data.notification);
break;
case 'count_update':
updateBadge(data.unread_count);
break;
}
};
Trigger all workflows matching an event.
curl -X POST http://localhost:3400/v1/workflows/trigger \
-H "X-Api-Key: sk_myapp_xxx" \
-H "Content-Type: application/json" \
-d '{
"event": "order.completed",
"subscriber_id": "user-42",
"payload": {
"order_id": "ORD-42",
"total": 99.00
}
}'
No authentication required.
curl http://localhost:3400/v1/health
{
"status": "ok",
"db": "ok",
"version": "0.2.2"
}
Requires admin API key (x-api-key or Authorization: Bearer).
{
"jobs_pending": 12,
"jobs_processing": 3,
"jobs_sent_24h": 1547,
"jobs_failed_24h": 2,
"subscribers_total": 8420,
"inbox_messages_total": 34210,
"active_workflow_runs": 5,
"uptime_seconds": 86400
}
Same admin key, Prometheus text exposition (point a Prometheus / Grafana
Agent scrape at it with bearer_token). Series:
| Metric | Labels | Meaning |
|---|---|---|
notifyd_jobs_outcome_total |
channel, provider, outcome |
sent, retry, failed, rate_limited, skipped |
notifyd_provider_errors_total |
channel, provider, kind |
rate_limited, transient, permanent, suppressed |
notifyd_lane_pauses_total |
channel |
lane pauses after a 429 |
notifyd_send_latency_seconds |
channel, provider |
provider call latency histogram |
notifyd_jobs_queue_depth |
status |
pending, retry, processing at scrape time |
notifyd_oldest_pending_age_seconds |
band |
oldest waiting job per priority band (urgent <50, normal, bulk ≥80) |
Users can opt out of specific channels or workflows.
Get preferences:
curl http://localhost:3400/v1/subscribers/user-1/preferences \
-H "X-Api-Key: sk_myapp_xxx"
Set preferences:
curl -X PUT http://localhost:3400/v1/subscribers/user-1/preferences \
-H "X-Api-Key: sk_myapp_xxx" \
-d '{
"preferences": [
{"channel": "email", "workflow_id": "marketing", "enabled": false},
{"channel": "sms", "workflow_id": "*", "enabled": false}
]
}'
Hierarchy: workflow-specific > channel-wide > global.
Everything an operator (or an agent, see docs/AGENT.md) needs. Admin key;
the read-only key (READONLY_API_KEY) may call the GET endpoints of this
table, /v1/metrics* and GET /v1/admin/projects.
| Endpoint | Purpose |
|---|---|
GET /v1/admin/digest?window=24h&format=json\|markdown |
Findings ranked by severity with the action to take, queue, outcomes, failure reasons, retries waiting, latency, deliverability, projects. window: 1h, 6h, 24h, 7d, 30d. |
GET /v1/admin/metrics/templates?window=7d&bucket=1d&project_id= |
Delivery funnel per template and time bucket: sent, failed, delivered, bounced, complained, opened, clicked. Template = template_id, else template tag, else category tag, else untemplated. |
GET /v1/admin/jobs?project_id=&status=&channel=&recipient=&since=&limit= |
Search jobs across projects (recipients masked, default last 7 days, 50 rows, max 500). |
POST /v1/admin/jobs/:id/retry |
Re-queue a failed/cancelled job with a fresh attempt budget. |
POST /v1/admin/jobs/:id/cancel |
Cancel a pending/retry job. |
PATCH /v1/admin/projects/:id |
{ name?, channels?, from_email?, from_name?, rate_limit_per_min?, send_window? } — keys untouched; empty from_email clears it; send_window (object or null) is the project’s daily window for bulk email. |
GET /v1/admin/suppressions?project_id=&limit= |
Active suppressions, masked. |
POST /v1/admin/suppressions |
{ project_id, email, detail? } — manual block. |
DELETE /v1/admin/suppressions/:id |
Release a suppression. |
Project keys have the scoped equivalents POST /v1/jobs/:id/retry and
POST /v1/suppressions ({ email, detail? }).
POST /mcp — Model Context Protocol, Streamable HTTP, stateless, dual-era
(2026-07-28 with server/discover and _meta, plus the legacy initialize
handshake); admin key as Authorization: Bearer. Origin, when present,
must be in CORS_ORIGINS (403). One JSON-RPC message per request (batches
→ 400). Tools with annotations and outputSchema: digest, list_jobs,
get_job, retry_job, cancel_job, list_projects, update_project,
list_suppressions, add_suppression, release_suppression, send_test.
See docs/AGENT.md.
Create a project:
curl -X POST http://localhost:3400/v1/admin/projects \
-H "X-Api-Key: admin_xxx" \
-d '{"id": "newapp", "name": "New App", "channels": ["email", "in_app"], "from_email": "hello@example.com"}'
# → {"success": true, "project": {"id": "newapp", "api_key": "sk_newapp_…"}, "warning": "…"}
# The key is returned once; only its hash is stored. An existing id answers 409:
# change settings with PATCH, issue a new key with rotate-key.
Rotate API key (zero-downtime):
# 1. Rotate — the old key keeps working as secondary until revoked
curl -X POST http://localhost:3400/v1/admin/projects/newapp/rotate-key \
-H "X-Api-Key: admin_xxx"
# → {"success": true, "new_api_key": "sk_newapp_yyy", "note": "Old key is still valid as secondary…"}
# 2. Update your app with new key
# 3. Revoke old key
curl -X POST http://localhost:3400/v1/admin/projects/newapp/revoke-secondary \
-H "X-Api-Key: admin_xxx"
Receive a POST for each job event a project subscribes to. events names
them exactly: an event that is not listed is not sent. Omit secret to get a
generated one in the response.
curl -X POST http://localhost:3400/v1/admin/webhooks \
-H "X-Api-Key: admin_xxx" -H 'content-type: application/json' \
-d '{
"project_id": "myapp",
"url": "https://myapp.com/webhooks/notifyd",
"events": ["job.sent", "job.failed", "job.delivered", "job.bounced"],
"secret": "whsec_xxx"
}'
| Event | When |
|---|---|
job.sent |
the provider accepted the message, or notifyd skipped it (opt-out, no push token), which the job shows as provider: "skipped" |
job.failed |
the job ended failed (permanent error, or attempts exhausted) |
job.delivered |
Twilio or smsmode confirmed an SMS delivered (SMS Delivery Status) |
job.bounced |
an email bounced (Resend, any bounce type) or an SMS came back undelivered / failed (Twilio), UNDELIVERABLE / UNDELIVERED (smsmode) |
job.complained |
the recipient marked the email as spam (Resend) |
Payload: {"event", "job_id", "channel", "subscriber_id", "timestamp"}. It is
signed with HMAC-SHA256 over the raw body, hex digest in the
X-Notifyd-Signature header. Each event is posted once, without retry, and
events of one job can arrive in any order: job.sent may come after
job.delivered or job.bounced. The job (GET /v1/jobs/:id) stays the source
of truth.
Default: 100 requests/minute per project. Configurable per project.
When rate limited, you’ll receive:
HTTP 429 Too Many Requests
{"error": "Rate limit exceeded"}
Retry after 60 seconds or contact the admin to increase limits.
notifyd-sdk — TypeScript client shipped from this repoInstall from GitHub:
pnpm add notifyd-sdk@github:rmzlb/notifyd
Usage:
import { createNotifydClient } from 'notifyd-sdk';
const notifyd = createNotifydClient({
url: process.env.NOTIFYD_URL!,
apiKey: process.env.NOTIFYD_API_KEY!,
});
await notifyd.send({
channel: 'email',
subscriberId: 'user-1',
subject: 'Welcome!',
body: 'Hello !',
vars: { first_name: 'Alice' },
});
const inbox = createNotifydClient({
url: process.env.NEXT_PUBLIC_NOTIFYD_URL!,
subscriberToken: token,
});
const messages = await inbox.getInbox('user-1', { limit: 20 });
The SDK wraps send, subscribers, subscriber JWT creation, inbox reads, unread count, mark read, mark all read, and SSE stream setup.
The API is still simple REST + SSE. Any HTTP client works. See the examples above in curl, TypeScript, and Rust.
“Sent” only means the provider accepted the API call. Resend webhooks close the loop: notifyd records what actually happened (delivered, bounced, complained) and stops writing to addresses that bounced or complained.
POST /webhooks/resend (note: not under /v1) receives Resend events,
authenticated by their svix signature — no API key. Configure it once:
https://<your-notifyd>/webhooks/resend with the events
email.delivered, email.bounced, email.complained.RESEND_WEBHOOK_SECRET env var and restart.
Without it the endpoint answers 503 and nothing is ingested (fail closed).Every accepted event is stored in provider_events (idempotent on the svix
message id). Effects per event:
| Event | Effect |
|---|---|
email.delivered |
stamps jobs.delivered_at |
email.bounced (Permanent) |
job status → bounced, error = bounce message, suppression created |
email.bounced (Transient) |
recorded only — soft bounces resolve on their own |
email.complained |
job stays sent (it WAS delivered), suppression created |
Events map back to jobs through the notifyd_job_id tag that the worker adds
to every outgoing email. Projects subscribed to outbound webhooks also receive
job.bounced / job.complained.
Every email that is bulk (priority ≥ 80, or tagged category=campaign|marketing|newsletter)
leaves with List-Unsubscribe: <PUBLIC_URL/u/<token>> and
List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058), unless the
caller set its own List-Unsubscribe header. Requires PUBLIC_URL on the
instance. The token is an HMAC (JWT_SECRET) over project, address and a
400-day expiry: nothing to store, nothing to guess.
GET /u/:token shows a confirmation page with one button (a GET never
changes state: mail scanners follow links).POST /u/:token records the unsubscribe: a suppression with
reason = unsubscribe, scope = marketing. Bulk email to the address
stops; transactional email (orders, security codes) still goes.Suppressions carry a scope: all (bounce, complaint, manual block: nothing
is sent) or marketing. POST /v1/suppressions and
POST /v1/admin/suppressions accept "scope": "all" | "marketing".
An active suppression (project + address) makes the worker fail every email
job to that address immediately — status: failed, error
recipient suppressed: … — without calling the provider. Releasing it is an
audited decision, never a deletion.
List suppressions
curl https://notifyd.example.com/v1/suppressions \
-H "X-Api-Key: sk_your_project_key"
# → { "data": [ { "id", "email", "reason", "detail", "created_at", "released_at" } ] }
# ?include_released=true also returns historical (released) rows
Release a suppression (allow sending to the address again)
curl -X DELETE https://notifyd.example.com/v1/suppressions/<id> \
-H "X-Api-Key: sk_your_project_key"
# → { "success": true, "id": "..." }
If the address bounces again after a release, a fresh suppression is created next to the released one — the history tells the whole story.
“Sent” on a Twilio SMS only means Twilio accepted the API call. When the
instance has PUBLIC_URL, each Twilio message leaves with
StatusCallback=PUBLIC_URL/webhooks/twilio/status?job=<job id> and Twilio
reports the carrier’s outcome there. Nothing to configure at Twilio: the
callback URL travels with each message. Without PUBLIC_URL, the request
carries no StatusCallback and SMS jobs stay sent.
Not under /v1, no API key: authenticated by X-Twilio-Signature, the
base64 HMAC-SHA1 keyed by TWILIO_AUTH_TOKEN of the URL (PUBLIC_URL, then
the path and query; never the Host header) followed by each POST parameter’s
name and value, sorted by name. The URL is also tried with the port toggled,
as Twilio’s SDK validators do: the default port added when PUBLIC_URL has
none (:443, or :80 on http), or the port PUBLIC_URL names removed. The
comparison is constant-time.
| Answer | When |
|---|---|
400 |
before the signature: a malformed query string (e.g. job given twice) |
413 |
before the signature: a body over 64 KiB |
415 |
before the signature: a body that is not application/x-www-form-urlencoded |
503 |
the route cannot authenticate anything: no SMS connector, or TWILIO_AUTH_TOKEN or PUBLIC_URL is missing (it stays on under SMS_PROVIDER=smsmode while TWILIO_AUTH_TOKEN is set) |
403 |
missing or invalid signature |
400 |
signed, but without MessageSid or MessageStatus |
200 |
recorded or ignored: a missing, malformed or unknown job id, or a MessageSid other than the one the job holds (an earlier attempt), changes nothing |
500 |
the job could not be read or updated (database error, logged with the job id) |
Effects on the SMS job named by job:
MessageStatus |
Job | Event |
|---|---|---|
delivered |
delivered_at stamped once, status unchanged; never on a bounced job. It can land on a job still processing, or on a retry / failed one when an attempt’s answer from Twilio was lost but Twilio took the message |
job.delivered |
undelivered, failed |
from sent, or from processing when the callback beats the worker’s write of the send (that write then keeps the bounce); never on a delivered job: status: "bounced", bounced_at, error: "twilio <ErrorCode>" |
job.bounced |
| anything else | nothing | none |
The first terminal status wins and each event fires once: a repeated or late
callback is acknowledged and ignored. To, From and any message text Twilio
posts are never logged, stored or returned.
Same rules as Twilio’s. When the instance has PUBLIC_URL and
SMSMODE_WEBHOOK_SECRET, each smsmode message leaves with
callbackUrlStatus=PUBLIC_URL/webhooks/smsmode/status?job=<job id> and
refClient=<job id>. In smsmode, set the signing secret (Paramètres >
Webhooks, signature HMAC SHA-256) to the value of SMSMODE_WEBHOOK_SECRET.
Not under /v1, no API key: authenticated by X-Smsmode-256:
sha256=<hex>, the HMAC-SHA256 keyed by SMSMODE_WEBHOOK_SECRET of the raw
body, compared in constant time.
| Answer | When |
|---|---|
413 |
before the signature: a body over 64 KiB |
503 |
the route cannot authenticate anything: no SMS connector or no SMSMODE_WEBHOOK_SECRET |
403 |
missing or invalid signature |
400 |
signed, but not JSON, or without messageId or status |
200 |
recorded or ignored: an incoming message (direction: MO), a missing or unknown job (?job=, else refClient), or a messageId other than the one the job holds changes nothing |
500 |
the job could not be read or updated (database error, logged with the job id) |
status.value |
Job | Event |
|---|---|---|
DELIVERED |
delivered_at stamped once, status unchanged; never on a bounced job |
job.delivered |
UNDELIVERABLE, UNDELIVERED |
from sent or processing, never on a delivered job: status: "bounced", bounced_at, error: "smsmode <status.detail>" |
job.bounced |
anything else (SCHEDULED, ENROUTE) |
nothing | none |
Per-user Telegram lets each owner (Baaton user) register their own bot, destination (chat), and deep-link pairing flow. The instance default bot (TELEGRAM_BOT_TOKEN / TELEGRAM_API_BASE) is unchanged.
Auth: project x-api-key on all management routes. The owner path parameter is a trusted opaque id supplied by Baaton after its own auth.
Encryption: bot tokens and webhook secrets are encrypted at rest using AES-256-GCM (TELEGRAM_ENCRYPTION_KEY, base64-encoded 32 bytes). Key absent → registration fails 503, no plaintext fallback.
Return current bot and destination for an owner.
curl https://notifyd.example.com/v1/telegram/alice \
-H "x-api-key: $PROJECT_KEY"
{
"data": {
"bot": {
"bot_username": "my_notify_bot",
"owned": true,
"source": "personal",
"webhook_registered": true,
"can_link": true
},
"destination": {
"channel": "telegram",
"address_masked": "***6789",
"verified": true,
"created_at": "2026-09-14T17:00:00Z",
"telegram_thread_id": null,
"telegram_bot_username": "my_notify_bot"
}
}
}
Register or update a personal bot. Notifyd calls getMe to validate the token, getWebhookInfo to check for a foreign webhook (409 if one exists that we did not register), then setWebhook.
webhook_base is <baaton_api_url>/api/v1/public/telegram/webhook — notifyd appends /<connection_uuid>.
curl -X PUT https://notifyd.example.com/v1/telegram/alice \
-H "x-api-key: $PROJECT_KEY" \
-H "content-type: application/json" \
-d '{"bot_token":"1234567:AAHsometoken","webhook_base":"https://api.baaton.dev/api/v1/public/telegram/webhook"}'
Response: same shape as GET.
Rules
webhook_base must start with https://TELEGRAM_BOT_TOKENTELEGRAM_ENCRYPTION_KEY is absentRemove personal bot (best-effort deleteWebhook), destination, and link tokens. Returns 204.
curl -X DELETE https://notifyd.example.com/v1/telegram/alice \
-H "x-api-key: $PROJECT_KEY"
Silently fails if no bot is registered (404).
Validate and store a destination (chat id or username). Calls getChat on the personal bot; validates telegram_thread_id requires a forum supergroup.
curl -X PUT https://notifyd.example.com/v1/telegram/alice/destination \
-H "x-api-key: $PROJECT_KEY" \
-H "content-type: application/json" \
-d '{"address":"@mygroup","telegram_thread_id":42}'
{
"data": {
"channel": "telegram",
"address_masked": "***1234",
"verified": true,
"created_at": "2026-09-14T17:01:00Z",
"telegram_thread_id": 42,
"telegram_bot_username": "my_notify_bot"
}
}
verified: true means getChat succeeded — reachability, not identity.
Remove the destination record. Returns 204.
Generate a one-time deep link so the owner can connect a private chat or group.
curl -X POST https://notifyd.example.com/v1/telegram/alice/link \
-H "x-api-key: $PROJECT_KEY" \
-H "content-type: application/json" \
-d '{"kind":"private"}'
{
"data": {
"deep_link": "https://t.me/my_notify_bot?start=abcdef1234567890abcdef1234567890",
"expires_at": "2026-09-14T17:16:00Z",
"command": "/start abcdef1234567890abcdef1234567890"
}
}
kind: "private" (default) or "group"/startBatch-resolve verified destinations by owner list. Returns raw addresses for worker routing (internal API).
curl -X POST https://notifyd.example.com/v1/telegram/lookup \
-H "x-api-key: $PROJECT_KEY" \
-H "content-type: application/json" \
-d '{"owners":["alice","bob","carol"]}'
{
"data": [
{
"owner": "alice",
"route_id": "01234567-89ab-cdef-0123-456789abcdef",
"address": "123456789",
"telegram_thread_id": null,
"bot_username": "my_notify_bot",
"verified": true
}
]
}
Telegram update callback. Not project-auth’d; authenticates via X-Telegram-Bot-Api-Secret-Token header (per-bot, decrypted at request time).
Baaton proxies transparently: POST /api/v1/public/telegram/webhook/:id → notifyd POST /v1/telegram/webhooks/:id.
Handles /start <token> commands to complete the link pairing flow. Always returns 2xx (Telegram retries on non-2xx). Returns 503 on DB failure.
Include chat.telegram_route_id (the route_id UUID from /v1/telegram/lookup) in the /v1/send call:
{
"channel": "telegram",
"to": "123456789",
"body": "Hello Alice!",
"chat": { "telegram_route_id": "01234567-89ab-cdef-0123-456789abcdef" }
}
The worker loads credentials by (route_id, project_id). A missing, unverified, or cross-project route fails permanently — never falls back to the instance bot.