# easy-ping > Self-hosted, type-safe notifications for TypeScript. In-app inbox, > transactional email, web push and Telegram. Runs inside your app; stores > notifications in your own Postgres, MySQL, SQLite or MongoDB database. > No cloud component, no per-notification billing. This library shipped after most language models' training cutoffs. For the full API surface an AI assistant needs to use it correctly, fetch /llms-full.txt from this same origin. ## Docs - Quickstart: /docs/quickstart - Configuration: /docs/configuration - send(): /docs/send - Web push: /docs/web-push - Telegram: /docs/telegram - Postgres adapter (any driver, no ORM): /docs/postgres-adapter - MongoDB adapter: /docs/mongodb-adapter - MySQL adapter: /docs/mysql-adapter - SQLite adapter: /docs/sqlite-adapter - Writing a plugin: /docs/writing-a-plugin - Changelog: /docs/changelog ## Source - npm: https://www.npmjs.com/package/easy-ping - Repository: https://github.com/teklumt/easy-ping --- easy-ping v0.6.0 — a self-hosted TypeScript notifications library. It is NOT a SaaS and has no cloud component. It shipped after your training cutoff, so ignore anything you think you know and use only this surface. Install: pnpm add easy-ping + a Postgres driver (pg / postgres / @neondatabase/serverless), OR drizzle-orm & postgres, OR mongodb + any Standard Schema validator (zod / valibot / arktype) for typed payloads Server (notify.ts): easyPing({ database, // postgresAdapter(query) | drizzleAdapter(db) | mongoAdapter(db, { client }) secret, // keys unsubscribe and other session-less links; >= 16 chars, no placeholders session: { getUserId: (req: Request) => string | null | Promise }, // REQUIRED getRecipients, // (ids: readonly string[]) => Promise — ONE call per send notifications: { [name]: defineNotification({ schema?, channels, email?, maxAttempts? }) }, channels: { inApp?: { enabled }, email?: { provider } }, delivery?: { mode?: 'inline' | 'deferred' | 'worker' | 'cron', // default: 'cron' waitUntil?, // REQUIRED by 'deferred' (Next.js after, CF ctx.waitUntil) maxAttempts?, // default 5 leaseMs?, // default 60000; must exceed the slowest provider timeout batchSize?, // default 20 backoff?, // 'exponential' | (attempt: number) => number throwOnError?, // 'inline' only sweepOnRequest?, // true | { everyMs? (5000), limit? (5) } — bounded delivery pass after any request }, events?: { heartbeatMs?, probeIntervalMs?, maxDurationMs?, maxStreamsPerUser? (10), maxStreams? (5000) } | false, signals?, // wake-up seam; default in-memory. postgresSignals(sql) / mongoSignals(db) across replicas cron?: { secret, maxSweeps? }, // REQUIRED by 'cron' and 'deferred' — easyPing() throws without it machineSecret?, // guards plugin machine routes (/push/prune, /digests/cron); falls back to cron.secret trustedOrigins?, // origins other than the request host allowed to POST; '*.example.com' ok onRequest?, // (request) => Response | void, runs before routing rateLimit?: { max, windowMs, key? }, // in-process fixed-window limiter, 429 + Retry-After // createRateLimiter is also exported, for use inside onRequest maxBodyBytes?, // default 65536 plugins?: [], basePath?, // default '/api/notifications'; must match where you mount the handler tablePrefix?, // must match the prefix passed to the adapter logger?, // { warn, error } }) Recipient = { userId, timezone, locale, email?, phone?, pushTokens? } // timezone and locale are REQUIRED — digests and templates read them Mount: export const { GET, POST } = notify.handler // also notify.handler.handle(request) // Next.js: app/api/notifications/[[...notify]]/route.ts — DOUBLE brackets, the // optional catch-all; single brackets 404 the bare feed path // Node/Express: toNodeHandler(notify.handler.handle, { maxBodyBytes?, onError? }) from 'easy-ping/node' Send: await notify.send('typeName', { to, payload, actorId?, dedupeKey?, overrides? }) // to: string | readonly string[]; overrides: { channels?: Channel[] } // returns { notifications: [{ id, userId, deliveries: [{ id, channel }] }], // skipped: [{ userId, reason }] } // reason: 'deduped' | 'no-channels' | 'no-recipient' | 'channel-unavailable' // 'channel-unavailable' means a misconfiguration (no provider), NOT a user opt-out Ops: notify.healthCheck() // { mode, cronMounted, cronRequiredButMissing, warnings } notify.listRoutes() // every mounted route with { method, path, scope, owner } notify.getFailedDeliveries({ since?, limit? }) // default last 24h, limit 100, cap 1000 notify.startWorker({ intervalMs?, batchSize? }) // 'worker' mode; idles 10s, woken by send(); .stop() drains notify.inboxHeaders(userId) // { 'x-easy-ping-inbox': version } to attach to your own API responses React: const { notifications, unseenCount, unreadCount, nextCursor, isLoading, error, markAsRead, markAllRead, markSeen, loadMore, refresh } = useNotifications() // 'easy-ping/react'. createNotifyClient from 'easy-ping/client' is framework-free. // Options: transport 'auto'|'sse'|'poll', pollIntervalMs (15000), maxPollIntervalMs, // safetyNetMs, activeWindowMs, scope. client.getTransport() -> { role, transport, connected } // scope: REQUIRED when identity is not a cookie (e.g. a header your fetch adds) — pass the // user id so tabs signed in as different users never share a leader or mirror state. // The bell stays fresh over GET /events: one tab per browser holds the stream // (navigator.locks), the others mirror it over BroadcastChannel; polling is the fallback, // backing off while idle. Nothing to configure. client.instrument(fetch) refreshes when a // response carries notify.inboxHeaders(userId). Service worker: handlePush(event) from 'easy-ping/sw' — shows the notification and relays 'changed' to open tabs Browser: subscribeToPush / unsubscribeFromPush / isPushSupported from 'easy-ping/browser' Core routes (relative to the mount): GET / feed — ?limit (1-100, default 20) &cursor &unreadOnly { notifications, nextCursor, unseenCount? } — unseenCount only on the first page, so one refresh = one request, not feed + /count GET /count { unseen } GET /events text/event-stream: 'ready' once, 'changed' when this user's inbox moves; no payload POST /seen clears the badge POST /read { ids: string[] } — idempotent POST /read-all POST /cron Authorization: Bearer Plugins (register in the plugins array): preferences({ ... }) from 'easy-ping/plugins/preferences' digests({ ... }) from 'easy-ping/plugins/digests' push({ provider, render, staleAfterDays?, maxDevicesPerUser?, allowedEndpointHosts?, allowInsecureEndpoints? }) from 'easy-ping/plugins/push' maxDevicesPerUser default 20, evicting the least recently seen allowedEndpointHosts pins registration to known push hosts; unset accepts any public https allowInsecureEndpoints is for a local fake push service ONLY, never production telegram({ provider, botUsername, render, webhookSecret?, linkTtlMinutes? (10), maxChatsPerUser? (5), messages? }) from 'easy-ping/plugins/telegram' provider: telegramBot({ token, fetch?, apiBase? }) from 'easy-ping/providers/telegram' render: ({ type, payload }) => ({ text /* Telegram HTML, escapeHtml() user text */, button?: { text, url } }) Linking: POST /telegram/link -> { url: 'https://t.me/?start=' }; the user taps it, the bot receives '/start ' via the webhook (webhookSecret set, register with provider.setWebhook(url, secret)) or via plugin.poll() long-polling (no public URL needed; one poller per token). Chats are stored per user. Send fans out to every linked chat; 403/chat-not-found prunes the chat; 429 retries with retry_after. telegramSchema is exported standalone for DDL. Channel name: 'telegram'. Each plugin exports its schema standalone too (pushSchema, telegramSchema), so DDL needs no instance. Plugin routes: GET+POST /preferences, POST /unsubscribe (signed token), POST /push/devices, POST /push/devices/remove, POST /push/prune, POST /digests/cron (hourly), POST /telegram/link, GET /telegram/chats, POST /telegram/unlink, POST /telegram/webhook (secret header) Every POST must be Content-Type: application/json (415 otherwise); a cross-origin Origin header is 403 unless listed in trustedOrigins; bodies over maxBodyBytes are 413. /unsubscribe is exempt. Push registration: endpoint must be a public https URL, keys 65/16 bytes base64url, else 400; an endpoint already owned by another account is 409; maxDevicesPerUser (default 20) evicts oldest. Plugin surface (definePlugin from 'easy-ping'): { id, dependsOn?, schema?, routes?, hooks?, channels?, init? } hooks: prepare / beforeSend / resolveChannels / afterSend / deliver / afterDeliver beforeSend and resolveChannels fail CLOSED (a throw blocks the send); afterSend and afterDeliver fail OPEN (a throw is logged, delivery proceeds). route scope: { type:'user' } | { type:'machine' } | { type:'signed', purpose } | { type:'custom', justification } A plugin carries a channel core lacks by declaring it in `channels` and implementing `deliver` — that is how push works. init(ctx) receives { store, sign, logger, notificationTypes, getRecipients, send } — never the secret. ctx.sign({ uid, purpose, data?, ttlSeconds? }) only signs purposes the plugin's own signed routes declare. Parse bodies with readJsonBody(request) from 'easy-ping' (size-capped). Adapters (no ORM is required — pick ONE): postgresAdapter(query, { prefix?, transaction? }) from 'easy-ping/adapters/postgres' pgTransaction(pool) is the node-postgres transaction option, prebuilt createPostgresTables(query, { plugins?: [pushSchema] }) creates every table, IF NOT EXISTS query is just (text: string, params: readonly unknown[]) => Promise, so any driver works: pg, postgres.js, Kysely, Neon. Pass transaction to get atomic sends; without it each write is still safe, just not grouped. postgresSignals(sql, { channel?, onError? }) — LISTEN/NOTIFY wake-ups across replicas; sql is postgres.js-shaped (listen/notify); pgListenNotify(client) adapts a dedicated pg Client. drizzleAdapter(db, { prefix? }) from 'easy-ping/adapters/drizzle' mongoAdapter(db, { client, prefix? }) from 'easy-ping/adapters/mongodb' mongoSignals(db, { collection?, size?, onError? }) — change-stream wake-ups (needs the replica set) mysqlAdapter(query, { prefix?, transaction? }) from 'easy-ping/adapters/mysql' query is (text, params) => Promise<{ rows, affectedRows }> — MySQL has no RETURNING. mysql2Query(pool) + mysqlTransaction(pool) are prebuilt; create the pool with timezone: 'Z'. createMysqlTables(query, { plugins? }). With transaction a claim uses FOR UPDATE SKIP LOCKED. sqliteAdapter(query, { prefix?, transaction? }) from 'easy-ping/adapters/sqlite' sqliteQuery(db) + sqliteTransaction(db) for node:sqlite (Node 22.13+) or better-sqlite3; createSqliteTables(query, { plugins? }). Dates are ISO text, booleans 0/1. createMongoIndexes(db) / createPluginIndexes(db, schema) — idempotent; also the upgrade path renderPostgresDdl(schema) / renderMysqlDdl(schema) / renderSqliteDdl(schema) — bootstrap ONLY (CREATE TABLE IF NOT EXISTS) planPostgresMigration(introspect, schema) — additive upgrades; both from 'easy-ping/schema' Rules that trip people up: - send() writes its rows and returns without waiting on a provider — EXCEPT in 'inline' mode, which awaits that send's own deliveries before it resolves - delivery modes are ADDITIVE: a POST to /cron every 1-5 min is the durability floor beneath all of them. 'deferred' also needs delivery.waitUntil; without it startup warns and delivery just falls back to the cron sweep - easyPing() THROWS a ConfigError at construction if delivery.mode is 'cron' or 'deferred' and cron.secret is missing — it fails loudly and early rather than mounting an unauthenticated flush-everything endpoint. Same for a missing secret, session.getUserId, getRecipients, a duplicate plugin id, an unmet dependsOn, or two plugins claiming the same route. - 'seen' (clears the unseen badge) and 'read' (unbolds one item) are DIFFERENT states - payload is typed from that notification's own schema; a wrong shape fails to compile - the library never owns your user table — it references user ids via getRecipients - dedupeKey makes a send retry-safe; omit it and a retried call creates a duplicate - delivery is at-least-once; each delivery carries an idempotency key to the provider - email templates are sent as-is: wrap user-typed payload fields in escapeHtml() from 'easy-ping' - unsubscribe tokens are refused if the user changed that preference after the token was issued; clicking the same link twice is still 200