Public
Signup API with ntfy push alerts and an admin dashboard
admin-dashboardnewsletternotificationsntfyoauthsqlite
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

Newsletter Signup API

Email signup endpoint with flexible metadata storage, ntfy.sh push notifications for new submissions, and an admin dashboard behind Val Town login.

πŸ”— https://newsletter-signup.val.run Β· πŸ›  https://newsletter-signup.val.run/admin

How it works

Rendering mermaid diagram...

Every signup fans out to every enabled receiver configured in the dashboard. Delivery failures are logged, never surfaced to the person signing up β€” a broken receiver can't break your form.

Setup

  1. Open https://newsletter-signup.val.run/admin and log in with Val Town.
  2. Add a receiver: pick an unguessable topic (the topic name is effectively the password β€” anyone who knows it can read your notifications).
  3. Subscribe to that same topic in the ntfy app (iOS, Android, web, CLI).
  4. Hit Send test to confirm it arrives.

Receiver options

FieldNotes
NameLabel shown in the dashboard
Topicntfy topic β€” keep it long and random
Serverhttps://ntfy.sh or your own self-hosted ntfy
Priority1 (min) … 5 (max) β€” controls how loudly the app alerts
TagsComma-separated; ntfy maps many to emoji
AuthAccess token (tk_…) or user:password β€” only needed for protected topics
Notify onNew signup and/or details updated
EnabledPause a receiver without deleting it

Notifications include a click action + "Open admin" button pointing back at the dashboard. Uses ntfy's publish-as-JSON API.

Message format

Messages are plain text, deliberately. ntfy's Markdown support is web-app only β€” the Android/iOS apps render the raw characters, so **bold** and - item arrive as literal punctuation on a phone. Multi-line key: value text looks right on every client:

New newsletter signup          ← title

dana@acme.com                  ← body

plan: enterprise
company: Acme Corp
seats: 25
interests: ["ai","design"]

signup #22 Β· 2026-08-18 18:39:21 UTC

Guardrails in ntfy.ts: at most 12 fields (then (+N more fields)), each value collapsed to one line and clipped at 120 chars, keeping messages glanceable and well under ntfy's 4,096-byte limit. Nested objects/arrays are shown as compact JSON.

Filters

Each receiver can require specific fields before it fires β€” one rule per line, evaluated against the signup's details object plus email:

plan = enterprise      # equals (case-insensitive, numeric-aware)
plan != trial          # not equals
company exists         # provided and not empty
phone missing          # absent, null or empty
country in US, CA, MX  # any of
email ~ @acme.com      # contains
seats >= 10            # numeric: > >= < <=
utm.source = hn        # nested paths, and answers[0].value
# comments and blank lines are ignored

Set the mode to all rules must match or any rule may match. No filter = notify on every subscribed event. So a "sales" receiver can watch plan = enterprise, while a catch-all phone receiver stays unfiltered.

Semantics (the sharp edges)

CaseBehaviour
Array value, e.g. ["ai","design"]Matches if any element matches
!= on an absent fieldTrue β€” pair with field exists if presence matters
false, 0Count as present; "", " ", [], {} do not
Numeric compare on non-numeric valueNo match (never throws)
Filter that fails to parseFails open β€” sends anyway, error shown in the dashboard
Value that's only operator punctuation (plan === pro)Rejected as an error, so a typo can't silently create a rule that never matches
Send test buttonBypasses filters on purpose β€” it tests connectivity

Filters are deliberately fail-open: a broken filter that silently swallowed every alert would be worse than an extra push.

Seeing what filters did

Silence is ambiguous, so filtering is observable in two places:

  • Test filters card β€” paste (or reuse the latest signup's) email + details JSON and see per receiver whether it would send or would skip, and why. Nothing is sent or saved.
  • Recent notifications β€” logs sent / failed / skipped, with the skip reason spelled out, e.g. failed plan = enterprise β€” plan is free.

Login flow

std/oauth's callback always returns to / β€” it has no "return to where I was" option, so visiting /admin while logged out would otherwise dump you on the API docs after signing in. post-login.ts fixes that: the admin gate stashes the intended path in a short-lived post_login_redirect cookie, and a middleware on GET / resumes it once a session exists. Only same-origin absolute paths are honoured (no open redirects).

Rendering mermaid diagram...

Environment variables

KeyRequiredPurpose
ADMIN_API_KEYfor JSON admin endpointsX-API-Key value for /subscribers*
ADMIN_USERNAMESoptionalExtra Val Town usernames allowed into /admin, comma-separated. Members of this val's org always have access.

πŸ‘‰ Add ADMIN_USERNAMES here: https://www.val.town/x/colel/newsletter-signup/environment-variables?key=ADMIN_USERNAMES

ntfy credentials live in the database (managed from the dashboard), not in env vars.

Endpoints

MethodPathAuthDescription
POST/subscribePublicSubmit a signup β†’ notifies receivers
PUT/subscribe/:id/detailsPublicMerge extra details into a signup
GET/subscribersAPI KeyList all subscribers
PUT/subscribers/:idAPI KeyUpdate details or notes
DELETE/subscribers/:idAPI KeyRemove a subscriber
GET/adminVal Town loginDashboard: review signups, configure receivers

Usage

Subscribe (public):

curl -X POST https://newsletter-signup.val.run/subscribe \ -H "Content-Type: application/json" \ -d '{"email": "user@example.com", "details": {"any": "json"}}'

Admin requests:

curl https://newsletter-signup.val.run/subscribers \ -H "X-API-Key: your-admin-key"

Files

FileRole
main.tsHono app; mounts API + admin, wraps everything in Val Town OAuth
api.tsPublic + API-key JSON routes
ntfy.tsntfy publishing, message formatting, filter gate, test sends
filter.tsFilter parsing + evaluation (pure, no I/O)
db.tsSchema, migrations + queries (subscribers, receivers, delivery log)
post-login.tsReturn-to-destination across the OAuth round trip
admin/routes.tsxAuth gate + dashboard routes and form handling
admin/Layout.tsx, admin/Receivers.tsx, admin/Subscribers.tsx, admin/FilterTest.tsxReact (SSR) UI

Schema

newsletter_subscribers β€” id, email, details (JSON), developer_notes, created_at, updated_at

ntfy_receivers β€” id, name, server, topic, priority, tags, auth, events, filter, filter_mode, enabled, timestamps

ntfy_deliveries β€” id, receiver_id, receiver_name, subscriber_id, event, outcome (sent/failed/skipped), ok, status, error (failure message or filter reason), created_at

New columns are added by ensureColumns() in db.ts on boot, so deploys migrate in place.