How PulseKit works
A plain HTTP API in front of a queue and background workers. This guide walks the full path of one event — from the client call to the append-only delivery audit.
Overview
PulseKit accepts events and fans them out to every channel the project has enabled — email via Resend, Slack via incoming webhook, in-app notification rows — with exponential-backoff retries, per-project rate limiting, and an append-only audit trail. A WebSocket feed pushes delivery updates to the dashboard in real time.
The event path is deliberately boring: POST /api/v1/events ingests, a BullMQ job is enqueued, and 202 Accepted returns in the time it takes to write one Postgres row. A worker process does the fan-out afterwards.
Event path
- 01SDK requestOne authenticated call enters the pipeline. The SDK returns a receipt — { eventId, receivedAt } — or null on transient failure. A 4xx misuse throws PulseKitError.TypeScript
import { PulseKit } from 'pulsekit-sdk' const pulse = new PulseKit({ apiKey: 'pk_test_...' }) const receipt = await pulse.notify({ event: 'payment.failed', user: 'user_123', data: { amount: 499, reason: 'card_declined' }, }) // → { eventId: '...', receivedAt: '...' } — or null on transient failure - 02API persistsAPI writes one immutable row to the events table — the record is append-only and never updated.
- 03API enqueuesAPI enqueues a BullMQ job in Redis and returns 202 Accepted immediately, so delivery is fully asynchronous.
- 04Worker claimsThe worker claims the job and loads the project's channels config (email, slack, inapp).
- 05Resend sendsThe worker dispatches via Resend — branded HTML email with humanised payload details.
- 06Attempt recordedFailures and give-ups are logged to delivery_logs — append-only, never overwritten.DeliveredFailedPending
- 07Redis publishesThe worker publishes to the Redis pub/sub channel delivery_updates.
- 08Dashboard updatesThe WebSocket server broadcasts to connected dashboard clients — the live feed updates with no refresh.
Emit · Route · Observe
01 — Emit. You call notify once. The event is validated, persisted, and acknowledged before any delivery work starts. Rate limiting is checked here via an atomic Redis sliding-window Lua script.
02 — Route. The event is enqueued as a BullMQ job. The worker loads the project's channels config and fans out in a single pass — each channel isolated in its own try/catch, so a Slack failure never causes a duplicate email.
03 — Observe. Failures and give-ups append rows to the append-only delivery_logs table. After each attempt the worker publishes a delivery_update to Redis, which the WebSocket server broadcasts to connected dashboard clients.
Components
| Component | Role |
|---|---|
| Express API | Ingest, auth (cookie + API key), project & notification routes |
| BullMQ queue | Delivery jobs with retries and dead-letter queue |
| Worker | Channel fan-out: email / Slack / in-app + audit logging |
| Postgres | Canonical state — events, delivery_logs, notifications, projects |
| Redis | Rate limit counters, queue broker, delivery_update pub/sub |
| WebSocket | Shares the API port; broadcasts delivery updates to the dashboard |
Repo layout
apps/
api/ Express API + BullMQ worker
db/migrations/ 001–008 — canonical schema
src/controllers/ auth, event, notification, project
src/middleware/ apiKeyAuth, rateLimiter, authenticate
src/lib/ queue, redis, emailTemplate, websocket
src/workers/ email.worker.ts — the fan-out
web/ Next.js App Router dashboard
app/(dashboard)/ sidebar, project views, live feed
app/api/notifications/ proxy routes → Express
packages/
sdk/ pulsekit-sdk — publishable packageRun it yourself
Prerequisites: PostgreSQL (pulsedev / pulsedb) and Redis on localhost. Apply the migrations, seed one dev user + project, then start API and worker as separate processes.
cd apps/api npm install node --env-file=.env.local src/index.ts # Express + WebSocket, :8080 # worker (separate process) node --env-file=.env.local src/workers/email.worker.ts
Environment variables:
# apps/api/.env.local DATABASE_URL=postgres://pulsedev:pulse123@localhost:5432/pulsedb REDIS_URL=redis://localhost:6379 RESEND_API_KEY=re_... COOKIE_SECRET=<random-string> # FROM_EMAIL=notifications@yourdomain (prod sender; sandbox default locally)
# apps/web/.env API_URL=http://localhost:8080 NEXT_PUBLIC_WS_URL=ws://localhost:8080
Deployment
| Service | Platform | Notes |
|---|---|---|
| API + worker | Railway | Two services sharing the apps/api root |
| Dashboard | Vercel | Next.js App Router |
| Database | Neon | Postgres 18, sslmode=require |
| Redis | Upstash | Redis-compatible TCP mode — BullMQ needs real blocking commands |
Apply the eight migrations and seed on Neon before first deploy.
Tests
| Suite | Count | Covers |
|---|---|---|
| auth.integration | 7 | Register, login, cookie session, logout, protected routes |
| project.integration | 19 | CRUD, ownership scoping, channel merge, rate-limit validation, auth isolation |
| event.integration | 8 | Ingest + queue assertion, no-channels warning, validation, auth isolation |
| notification.integration | 3 | Inbox, read state, auth isolation |
| ratelimit.integration | 2 | 30 pass → 429 on the 31st, window reset |
| rateLimiter.failopen | 1 | Redis down → fail-open, mocked Redis |
| ratelimiter unit | 4 | Sliding-window Lua semantics |
| sdk contract | 16 | Mocked-fetch notify() covering the full SDK contract |
API suites run serially against a dedicated pulsedb_test database through real Postgres and Redis; the event suite asserts queue state (getJobCounts) rather than delivery rows, so results are deterministic with or without a live worker.