notifyd

Logo

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.

View the Project on GitHub rmzlb/notifyd

notifyd API Reference

Base URL: http://localhost:3400 (dev) or your deployed domain.

All endpoints require X-Api-Key: sk_<project>_xxx header unless noted otherwise.


Quick Start

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."
  }'

Authentication

API Key (backend-to-backend)

X-Api-Key: sk_myapp_xxxxxxxxxxxxxxxxxxxxx

Defined per project in notifyd.toml. Identifies the project and scopes all data.

Subscriber JWT (frontend)

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...

Admin Key

Some endpoints (/v1/metrics, /v1/admin/*) require the admin API key defined in config.


Response Format

All responses are JSON.

Success

{"success": true, "id": "uuid", ...}

Error

{"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

Endpoints

Summary

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

POST /v1/send

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.

GET /v1/jobs/:id

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?;

POST /v1/batch

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.


GET /v1/channels

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}
  }
}

GET /v1/inbox/:subscriber_id

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"
}

GET /v1/inbox/:subscriber_id/stream

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;
  }
};

POST /v1/workflows/trigger

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
    }
  }'

GET /v1/health

No authentication required.

curl http://localhost:3400/v1/health
{
  "status": "ok",
  "db": "ok",
  "version": "0.2.2"
}

GET /v1/metrics

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
}

GET /v1/metrics/prometheus

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)

Subscriber Preferences

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.


Admin: Operator surface

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? }).

MCP endpoint

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.

Admin: Projects

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"

Admin: Webhooks

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.


Rate Limits

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.


SDKs

Official

Install 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.

Roll Your Own

The API is still simple REST + SSE. Any HTTP client works. See the examples above in curl, TypeScript, and Rust.

Email Deliverability

“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.

Webhook ingestion

POST /webhooks/resend (note: not under /v1) receives Resend events, authenticated by their svix signature — no API key. Configure it once:

  1. Create a webhook in Resend pointing at https://<your-notifyd>/webhooks/resend with the events email.delivered, email.bounced, email.complained.
  2. Put its signing secret in the 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.

Commercial unsubscribe (bulk email)

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.

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".

Suppression list

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.

SMS Delivery Status (Twilio)

“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.

POST /webhooks/twilio/status

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.

SMS Delivery Status (smsmode)

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.

POST /webhooks/smsmode/status

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

Telegram per-user credentials

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.


GET /v1/telegram/:owner

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"
    }
  }
}

PUT /v1/telegram/:owner

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


DELETE /v1/telegram/:owner

Remove 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).


PUT /v1/telegram/:owner/destination

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.


DELETE /v1/telegram/:owner/destination

Remove the destination record. Returns 204.


POST /v1/telegram/:owner/link

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"
  }
}

POST /v1/telegram/lookup

Batch-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
    }
  ]
}

POST /v1/telegram/webhooks/:id (public)

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.


Sending via a personal route

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.