Skip to content
Architecture

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.

01

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.

02

Event path

  1. 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
  2. 02API persistsAPI writes one immutable row to the events table — the record is append-only and never updated.
  3. 03API enqueuesAPI enqueues a BullMQ job in Redis and returns 202 Accepted immediately, so delivery is fully asynchronous.
  4. 04Worker claimsThe worker claims the job and loads the project's channels config (email, slack, inapp).
  5. 05Resend sendsThe worker dispatches via Resend — branded HTML email with humanised payload details.
  6. 06Attempt recordedFailures and give-ups are logged to delivery_logs — append-only, never overwritten.DeliveredFailedPending
  7. 07Redis publishesThe worker publishes to the Redis pub/sub channel delivery_updates.
  8. 08Dashboard updatesThe WebSocket server broadcasts to connected dashboard clients — the live feed updates with no refresh.
03

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.

04

Components

ComponentRole
Express APIIngest, auth (cookie + API key), project & notification routes
BullMQ queueDelivery jobs with retries and dead-letter queue
WorkerChannel fan-out: email / Slack / in-app + audit logging
PostgresCanonical state — events, delivery_logs, notifications, projects
RedisRate limit counters, queue broker, delivery_update pub/sub
WebSocketShares the API port; broadcasts delivery updates to the dashboard
05

Repo layout

tree
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 package
06

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

bash
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:

env
# 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)
env
# apps/web/.env
API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080
07

Deployment

ServicePlatformNotes
API + workerRailwayTwo services sharing the apps/api root
DashboardVercelNext.js App Router
DatabaseNeonPostgres 18, sslmode=require
RedisUpstashRedis-compatible TCP mode — BullMQ needs real blocking commands

Apply the eight migrations and seed on Neon before first deploy.

08

Tests

SuiteCountCovers
auth.integration7Register, login, cookie session, logout, protected routes
project.integration19CRUD, ownership scoping, channel merge, rate-limit validation, auth isolation
event.integration8Ingest + queue assertion, no-channels warning, validation, auth isolation
notification.integration3Inbox, read state, auth isolation
ratelimit.integration230 pass → 429 on the 31st, window reset
rateLimiter.failopen1Redis down → fail-open, mocked Redis
ratelimiter unit4Sliding-window Lua semantics
sdk contract16Mocked-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.