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
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.
- Open https://newsletter-signup.val.run/admin and log in with Val Town.
- Add a receiver: pick an unguessable topic (the topic name is effectively the password β anyone who knows it can read your notifications).
- Subscribe to that same topic in the ntfy app (iOS, Android, web, CLI).
- Hit Send test to confirm it arrives.
| Field | Notes |
|---|---|
| Name | Label shown in the dashboard |
| Topic | ntfy topic β keep it long and random |
| Server | https://ntfy.sh or your own self-hosted ntfy |
| Priority | 1 (min) β¦ 5 (max) β controls how loudly the app alerts |
| Tags | Comma-separated; ntfy maps many to emoji |
| Auth | Access token (tk_β¦) or user:password β only needed for protected topics |
| Notify on | New signup and/or details updated |
| Enabled | Pause 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.
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.
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.
| Case | Behaviour |
|---|---|
Array value, e.g. ["ai","design"] | Matches if any element matches |
!= on an absent field | True β pair with field exists if presence matters |
false, 0 | Count as present; "", " ", [], {} do not |
| Numeric compare on non-numeric value | No match (never throws) |
| Filter that fails to parse | Fails 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 button | Bypasses 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.
Silence is ambiguous, so filtering is observable in two places:
- Test filters card β paste (or reuse the latest signup's) email +
detailsJSON 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.
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...
| Key | Required | Purpose |
|---|---|---|
ADMIN_API_KEY | for JSON admin endpoints | X-API-Key value for /subscribers* |
ADMIN_USERNAMES | optional | Extra 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.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /subscribe | Public | Submit a signup β notifies receivers |
| PUT | /subscribe/:id/details | Public | Merge extra details into a signup |
| GET | /subscribers | API Key | List all subscribers |
| PUT | /subscribers/:id | API Key | Update details or notes |
| DELETE | /subscribers/:id | API Key | Remove a subscriber |
| GET | /admin | Val Town login | Dashboard: review signups, configure receivers |
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"
| File | Role |
|---|---|
main.ts | Hono app; mounts API + admin, wraps everything in Val Town OAuth |
api.ts | Public + API-key JSON routes |
ntfy.ts | ntfy publishing, message formatting, filter gate, test sends |
filter.ts | Filter parsing + evaluation (pure, no I/O) |
db.ts | Schema, migrations + queries (subscribers, receivers, delivery log) |
post-login.ts | Return-to-destination across the OAuth round trip |
admin/routes.tsx | Auth gate + dashboard routes and form handling |
admin/Layout.tsx, admin/Receivers.tsx, admin/Subscribers.tsx, admin/FilterTest.tsx | React (SSR) UI |
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.