[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Web Push Notification Service

Build a Web Push Notification Service

Build a web push notification system with subscription management, topic targeting, scheduled sends, click tracking and automatic cleanup of expired subscriptions.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Ren leads growth at a 25-person content platform. Readers visit, read one article and leave, and the email newsletter reaches only a fraction of them. Web push could bring them back, but it has to be done carefully: a permission prompt on the first visit gets blocked, notifications to everyone get ignored, and a server that sends to dead subscriptions forever wastes time. Ren needs subscription management, topic targeting, scheduled sends, click tracking and automatic cleanup.

The Solution

A small TypeScript service: Hono serves the HTTP API, Zod validates every request body, PostgreSQL stores subscriptions and campaigns, Redis counts clicks, and the web-push library encrypts and delivers messages through each browser's push service using VAPID keys. Checked against web-push 3.6, Hono 4, Zod 4, ioredis 6 and Node 24; run against real PostgreSQL 18 and Redis 8 with a local fake push service.

Step-by-Step Walkthrough

Step 1: Install and generate VAPID keys

bash
npm install web-push ioredis pg hono @hono/node-server @hono/zod-validator zod
npm install -D typescript tsx @types/web-push @types/pg @types/node
npx web-push generate-vapid-keys --json

Put the two keys in VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY, and set DATABASE_URL, REDIS_URL, ADMIN_TOKEN and PUSH_CONTACT_EMAIL (the VAPID contact). Keep the private key in your secret store, never in the repo. Do not change the key pair later: existing subscriptions are bound to the public key.

Step 2: Schema

sql
CREATE TABLE push_subscriptions (
  id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        text,
  endpoint       text NOT NULL UNIQUE,
  p256dh         text NOT NULL,
  auth           text NOT NULL,
  timezone       text NOT NULL DEFAULT 'UTC',
  topics         text[] NOT NULL DEFAULT '{}',
  status         text NOT NULL DEFAULT 'active',  -- active | expired | unsubscribed
  delivery_count int NOT NULL DEFAULT 0,
  click_count    int NOT NULL DEFAULT 0,
  last_sent_at   timestamptz,
  created_at     timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON push_subscriptions USING gin (topics);

CREATE TABLE push_campaigns (
  id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  title   text NOT NULL,
  body    text NOT NULL,
  url     text NOT NULL,
  image   text,
  topics  text[] NOT NULL DEFAULT '{}',   -- empty = everyone
  send_at timestamptz,
  status  text NOT NULL DEFAULT 'draft',  -- draft | scheduled | sending | sent
  stats   jsonb NOT NULL DEFAULT '{"sent":0,"delivered":0,"failed":0}'
);

Step 3: Push service

sendPush separates a revoked subscription (404 or 410 from the push service: mark it expired) from a temporary failure (leave it active). Campaigns are claimed with a single UPDATE ... WHERE status IN ('draft','scheduled'), so a double click or a second worker cannot send one twice.

typescript
// src/service.ts
import pg from "pg";
import { Redis } from "ioredis";
import webpush from "web-push";
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL!);
webpush.setVapidDetails(`mailto:${process.env.PUSH_CONTACT_EMAIL}`, process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!);
export type Sub = { id: string; endpoint: string; p256dh: string; auth: string };
export async function subscribe(
  s: { endpoint: string; keys: { p256dh: string; auth: string } },
  o: { userId?: string; timezone?: string; topics?: string[] },
) {
  const { rows } = await pool.query(
    `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, timezone, topics)
     VALUES ($1, $2, $3, $4, $5, $6)
     ON CONFLICT (endpoint) DO UPDATE SET
       user_id = COALESCE(EXCLUDED.user_id, push_subscriptions.user_id),
       p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth,
       topics = EXCLUDED.topics, status = 'active'
     RETURNING id`,
    [o.userId ?? null, s.endpoint, s.keys.p256dh, s.keys.auth, o.timezone ?? "UTC", o.topics ?? []]);
  return rows[0].id as string;
}
type Payload = { title: string; body: string; url: string; image?: string | null; campaignId?: string };
/** Returns "ok", "gone" (the browser revoked it) or "failed" (try again later). */
async function sendPush(sub: Sub, payload: Payload): Promise<"ok" | "gone" | "failed"> {
  try {
    await webpush.sendNotification(
      { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
      JSON.stringify({ ...payload, subscriptionId: sub.id }),
      { TTL: 86400, urgency: "normal", topic: payload.campaignId?.replaceAll("-", "").slice(0, 32) });
    await pool.query("UPDATE push_subscriptions SET last_sent_at = now(), delivery_count = delivery_count + 1 WHERE id = $1", [sub.id]);
    return "ok";
  } catch (err) {
    const code = (err as { statusCode?: number }).statusCode;
    if (code === 404 || code === 410) {
      await pool.query("UPDATE push_subscriptions SET status = 'expired' WHERE id = $1", [sub.id]);
      return "gone";
    }
    return "failed";
  }
}
export async function sendCampaign(id: string) {
  const { rows: [c] } = await pool.query(
    "UPDATE push_campaigns SET status = 'sending' WHERE id = $1 AND status IN ('draft','scheduled') RETURNING *", [id]);
  if (!c) throw new Error("Campaign not found or already sent");
  const { rows: subs } = await pool.query<Sub>(
    `SELECT id, endpoint, p256dh, auth FROM push_subscriptions
     WHERE status = 'active' AND (cardinality($1::text[]) = 0 OR topics && $1::text[])`, [c.topics]);
  const stats = { sent: subs.length, delivered: 0, failed: 0 };
  for (let i = 0; i < subs.length; i += 100) {
    const results = await Promise.all(
      subs.slice(i, i + 100).map((s) =>
        sendPush(s, { title: c.title, body: c.body, url: c.url, image: c.image, campaignId: id }),
      ),
    );
    for (const r of results) r === "ok" ? stats.delivered++ : stats.failed++;
  }
  await pool.query("UPDATE push_campaigns SET status = 'sent', stats = $2 WHERE id = $1", [id, stats]);
  return stats;
}
/** Call every minute from a timer or cron. sendCampaign claims a campaign atomically, so two workers cannot send it twice. */
export async function runDueCampaigns() {
  const { rows } = await pool.query(
    `SELECT id FROM push_campaigns WHERE status = 'scheduled' AND send_at <= now()
     ORDER BY send_at LIMIT 5`,
  );
  for (const r of rows) await sendCampaign(r.id).catch(() => {}); // another worker got it first
}
/** Count each subscription's click on a campaign once. */
export async function trackClick(subscriptionId: string, campaignId: string) {
  if ((await redis.sadd(`push:clicked:${campaignId}`, subscriptionId)) === 0) return;
  await redis.hincrby(`push:campaign:${campaignId}`, "clicks", 1);
  await pool.query("UPDATE push_subscriptions SET click_count = click_count + 1 WHERE id = $1", [subscriptionId]);
}
export async function campaignAnalytics(id: string) {
  const { rows: [c] } = await pool.query("SELECT stats FROM push_campaigns WHERE id = $1", [id]);
  const clicks = Number(await redis.hget(`push:campaign:${id}`, "clicks")) || 0;
  const { delivered, failed, sent } = c.stats;
  return { sent, delivered, failed, clicks, ctr: delivered ? +((clicks / delivered) * 100).toFixed(1) : 0 };
}

Step 4: HTTP API

The subscribe endpoint accepts only https endpoints, because the server will POST to whatever URL it is given; for stricter control, also allow-list the hosts of the push services you support. Admin routes sit behind a bearer token. Run it with PORT=3000 npx tsx src/api.ts; it binds to 127.0.0.1, so put your reverse proxy in front.

typescript
// src/api.ts
import { Hono } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { serve } from "@hono/node-server";
import { zValidator } from "@hono/zod-validator";
import * as z from "zod";
import { subscribe, sendCampaign, trackClick, campaignAnalytics, pool } from "./service.js";
const app = new Hono();
const subscribeBody = z.object({
  subscription: z.object({
    endpoint: z.url({ protocol: /^https$/ }), // the server will POST to this URL: https only
    keys: z.object({ p256dh: z.string().min(1), auth: z.string().min(1) }),
  }),
  timezone: z.string().max(64).optional(),
  topics: z.array(z.string().max(40)).max(20).optional(),
});
app.post("/api/push/subscribe", zValidator("json", subscribeBody), async (c) => {
  const { subscription, timezone, topics } = c.req.valid("json");
  return c.json({ id: await subscribe(subscription, { timezone, topics }) }, 201);
});
// Called by the service worker when a notification is clicked.
app.post("/api/push/click", zValidator("json", z.object({
  subscriptionId: z.uuid(), campaignId: z.uuid(),
})), async (c) => {
  const { subscriptionId, campaignId } = c.req.valid("json");
  await trackClick(subscriptionId, campaignId);
  return c.body(null, 204);
});
const admin = new Hono();
admin.use(bearerAuth({ token: process.env.ADMIN_TOKEN! }));
admin.post("/campaigns", zValidator("json", z.object({
  title: z.string().max(80), body: z.string().max(200), url: z.string().startsWith("/"),
  image: z.url({ protocol: /^https$/ }).optional(), topics: z.array(z.string()).default([]),
  sendAt: z.iso.datetime().optional(),
})), async (c) => {
  const b = c.req.valid("json");
  const { rows } = await pool.query(
    `INSERT INTO push_campaigns (title, body, url, image, topics, send_at, status)
     VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
    [b.title, b.body, b.url, b.image ?? null, b.topics, b.sendAt ?? null, b.sendAt ? "scheduled" : "draft"]);
  return c.json(rows[0], 201);
});
admin.post("/campaigns/:id/send", async (c) => c.json(await sendCampaign(c.req.param("id"))));
admin.get("/campaigns/:id/analytics", async (c) => c.json(await campaignAnalytics(c.req.param("id"))));
app.route("/api/admin", admin);
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000), hostname: "127.0.0.1" });

Step 5: Service worker and opt-in

Push requires HTTPS (or localhost). Ask for permission from a click handler, ideally after a reader has shown interest (for example after the third article), not on page load. On iPhone and iPad, web push works only for a site the user has added to the Home Screen (iOS 16.4 and later). The image option is honoured by Chromium browsers only, so never depend on it.

javascript
// public/sw.js
self.addEventListener("push", (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      image: data.image || undefined,
      tag: data.campaignId,
      data, // read again in notificationclick
    }),
  );
});
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const { url, subscriptionId, campaignId } = event.notification.data;
  event.waitUntil(Promise.all([
    clients.openWindow(url),
    campaignId && fetch("/api/push/click", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ subscriptionId, campaignId }),
      keepalive: true,
    }),
  ]));
});
javascript
// public/subscribe.mjs
// Run from a click handler: browsers ignore permission prompts that are not a user gesture.
export async function enablePush(vapidPublicKey) {
  const reg = await navigator.serviceWorker.register("/sw.js");
  if ((await Notification.requestPermission()) !== "granted") return null;
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: vapidPublicKey, // the base64url string from generate-vapid-keys
  });
  return fetch("/api/push/subscribe", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      subscription: subscription.toJSON(),
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      topics: ["articles"],
    }),
  });
}

Step 6: Schedule and send

Create a campaign, then either call POST /api/admin/campaigns/:id/send or give it a sendAt and run runDueCampaigns() from a one-minute timer:

console
$ curl -s -X POST -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
    http://127.0.0.1:3000/api/admin/campaigns \
    -d '{"title":"New: how we cut build times","body":"6 min read","url":"/a/build-times","topics":["articles"]}'
{"id":"85ca5afa-c213-4d11-8cb4-e568aa7f1d2d"}
$ curl -s -X POST -H "authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:3000/api/admin/campaigns/85ca5afa-c213-4d11-8cb4-e568aa7f1d2d/send
{"sent":2,"delivered":1,"failed":1}
$ curl -s -H "authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:3000/api/admin/campaigns/85ca5afa-c213-4d11-8cb4-e568aa7f1d2d/analytics
{"sent":2,"delivered":1,"failed":1,"clicks":1,"ctr":100}

"Delivered" means the push service accepted the message; it does not prove the notification was shown. Clicks are counted once per subscription per campaign.

Real-World Example

Ren's team adds the opt-in prompt to the article footer after a reader's third visit, with topic checkboxes (articles, weekly-digest). The editor schedules the weekly digest for Tuesday morning; the one-minute timer picks it up, the send marks expired browsers, and the Wednesday report shows delivered, failed and clicks per campaign. Because subscriptions that answer 410 are retired automatically, the audience list stays honest, and Ren can compare click rates between topics before deciding what to send more of.

Related Skills

  • typescript: strict types for the service and API
  • postgresql: subscription and campaign storage, array and GIN-index topic matching
  • redis: click counters and once-per-subscription deduplication
  • hono: the HTTP API and bearer-auth admin routes
  • zod: request validation, including the https-only endpoint check

Related use cases