Elixir and the BEAM for AI systems
Adding a Newsletter to a Phoenix Site With No Database
This site has no Ecto, no Repo, no database at all. Here's how the newsletter signup works when the system of record is a third-party API, not a table.
In this post
- Where do subscribers live, if not in a table?
- The two-call shape, and why the referrer matters
- What the controller checks before Kit ever sees an email
- Fail closed here, fail open there
- Ship dark, flip live: the two-env-var flag
- Reusing the contact form’s guardrails, not rebuilding them
- No JS, and then with JS
- What this bought, and what it didn’t
TL;DR: This site runs on Phoenix with no Ecto, no
Repo, no database anywhere in the stack. So when a “subscribe to the newsletter” box needed to go on every post, the first question wasn’t “which migration” — it was “where does a subscriber even live if there’s no table for one.” The answer: Kit (ConvertKit) is the system of record, and the app’s job shrinks to two HTTP calls and a form. The interesting parts are what those two calls actually do, one deliberate fail-closed decision that contradicts this same site’s other visitor-facing check, and a feature flag that lets the whole thing ship dark until two env vars exist.
Where do subscribers live, if not in a table?
They don’t live here at all — they live in Kit. mix.exs
has no :ecto, no :ecto_sql, no
:postgrex in its deps; there’s no
priv/repo/migrations directory to put a
subscribers table in even if I wanted one. That’s not an
oversight, it’s the shape of this whole site: content is files, not
rows, and the newsletter feature had to fit that constraint rather than
be the one place it broke.
So a “subscriber” isn’t a struct this app persists. It’s a record
Kit’s API owns, and this app’s job is to get an email address there
correctly, tell the visitor whether that worked, and then forget about
it. SublimeCoding.Kit
(lib/sublime_coding/kit.ex) is the entire client — one
public function, subscribe/2, wrapping the Kit v4
API behind Req.
The two-call shape, and why the referrer matters
A signup isn’t one API call, it’s two: upsert the subscriber, then add them to a specific form.
def subscribe(email, referrer) do
if enabled?() do
with :ok <- post("/v4/subscribers",
%{email_address: email}, email) do
post("/v4/forms/:form_id/subscribers",
%{email_address: email, referrer: referrer},
email)
end
else
{:error, :disabled}
end
endThe first call is an upsert against Kit’s global subscriber list — it
doesn’t care which post the visitor was reading. The second call is what
actually attributes the signup: it adds that subscriber to this
site’s form, and it carries referrer, the URL of the
page they signed up on. That’s the field Kit uses to answer “which post
converted this subscriber” later, and it’s the entire reason the signup
box needs to know what page it’s rendered on rather than just posting an
email address into a void.
The controller builds that URL rather than trusting anything the
client sends:
SublimeCodingWeb.Endpoint.url() <> source, where
source is a same-site path the controller has already
validated (newsletter_controller.ex,
source_path/1 — anything with a scheme, a host, or a
protocol-relative // collapses to /). The
newsletter box itself is a stateless function component,
newsletter_signup/1 in site_components.ex,
that takes a source attr and renders a hidden
<input name="source"> alongside the email field.
Every post page passes its own path in; the form doesn’t know or care
what post it’s on beyond that one string.
What the controller checks before Kit ever sees an email
None of the validation lives in a changeset, because there’s no
schema to build one against. NewsletterController.create/2
runs a plain function pipeline instead: normalize_email/1
trims whitespace, then validate_email/1 checks three things
before an address is allowed anywhere near an HTTP call — byte size at
or under 254 (the RFC
5321 cap on a forward path), no control characters, and a match
against a permissive [^\s@]+@[^\s@]+\.[^\s@]+ regex. That’s
deliberately loose as email regexes go; it’s a shape check, not a
mail-deliverability check, because Kit’s own confirmation email is the
actual proof the address is real.
source_path/1 gets the same treatment for the redirect
target and the referrer — capped at 300 bytes, and only a value
URI.new/1 parses with no scheme, no host, and a leading
/ survives; anything else, including the
/\host form some browsers still treat as
//host, falls back to /.
The with chain in create/2 — validate the
email, then verify the Turnstile challenge, then call
Kit.subscribe/2 — means a malformed address or a failed
challenge never reaches the network call at all. Kit only ever sees an
address that’s already passed the site’s own gate, which matters more
than usual given that shared 120-requests-per-minute ceiling on the API
key.
If you’re at the point where a form on your site needs to answer questions like “who signed up and from where” without a database of your own, that’s usually the moment worth a second set of eyes on the whole intake path — that’s the kind of gap review I do in a fractional engagement.
Fail closed here, fail open there
The moduledoc on SublimeCoding.Kit states the
fail-closed decision plainly, and it’s worth quoting rather than
softening: “Unlike Turnstile this fails closed: a transport error is
reported as a failure, because telling a visitor they subscribed when
Kit never heard about it is a lie with no recovery path.” The site’s
other visitor-facing check, Turnstile bot verification, fails open on
its own transport errors — a Cloudflare outage shouldn’t block every
legitimate contact-form submission. Kit doesn’t get that same leniency,
because the two failure modes aren’t symmetric: a Turnstile outage that
lets a few bots through is recoverable (delete the spam), but telling
someone “you’re subscribed” when the write never landed is a promise the
app can’t keep and the visitor has no way to notice was broken. Same
codebase, two different third-party dependencies, two opposite defaults
— because the cost of being wrong isn’t the same in both directions.
That’s the whole judgment call; it’s not a security essay, so I’ll leave
it there.
Ship dark, flip live: the two-env-var flag
The feature doesn’t get an if statement scattered
through the templates — it gets one predicate,
Kit.enabled?/0, checked in two places:
def enabled?, do: api_key() != "" and form_id() != ""newsletter_signup/1 calls it to decide whether the
<section> renders at all (:if={@enabled}
on the outer tag). NewsletterController.create/2 calls it
too, and when it’s false the controller raises
Phoenix.Router.NoRouteError — POST /subscribe
404s exactly as if the route didn’t exist, rather than returning a 200
that quietly does nothing. Both KIT_API_KEY and
KIT_FORM_ID have to be set, in
config/runtime.exs, for either the box or the endpoint to
exist. That’s the same ship-dark pattern I wrote up for secrets and
runtime.exs on Fly — and a newsletter key is exactly
the “genuinely optional config” that post carves out from
fetch_env!/1, so reading it with
System.get_env/2 behind a predicate is the right side of
that line rather than a lapse from it. Merge the code with the flag off
everywhere, then flip it live by setting two fly secrets,
no redeploy of the feature itself required. Nothing about the newsletter
code needed to be conditionally deployed — it needed to be conditionally
true.
Reusing the contact form’s guardrails, not rebuilding them
POST /subscribe doesn’t get its own bot check or its own
rate limiter. It reuses both from the contact form: the same
Turnstile.verify/2 call, and the same
RateLimit plug
(lib/sublime_coding_web/plugs/rate_limit.ex) that already
protects /contact, just keyed to its own
:newsletter bucket at 5 requests per 60 seconds per IP.
The per-IP limit matters more here than it does on a typical form,
and the reason is in the Kit moduledoc: “Kit limits a key
to 120 requests per rolling 60s; the per-IP rate limit on
POST /subscribe is what keeps a single visitor from
spending that budget.” Kit’s own docs put it per key: “no more
than 120 requests over a rolling 60 second period for a given API
Key” (OAuth gets 600). This site has exactly one key, so that
ceiling is shared by every visitor who ever touches the form — it is not
a budget per visitor. A form with no per-IP throttle in front of a
single key is one bored or hostile visitor away from locking out every
real signup on the site for a minute at a time. I’m not re-explaining
Turnstile setup or the rate limiter’s ETS internals here — the plug’s
own moduledoc covers why that table has to be owned by a long-lived
process, and the short version is that a lazily created ETS table dies
with the request that made it, which once made the limiter a silent
no-op in production while the test suite still passed. The point is just
that this endpoint didn’t need new plumbing, it needed to sit behind the
plumbing that already existed.
No JS, and then with JS
The form works with no client-side JavaScript at all: a plain
<form method="post" action="/subscribe">, and the
controller’s respond/4 redirects back to the page with a
flash message on both success and failure. That’s the entire feature,
and it would ship fine stopping there.
assets/js/newsletter-form.js adds a layer on top rather
than replacing it. It listens for submit on
document (delegated, so it survives a LiveView
connected-render on the vCISO cost tool page without needing to rebind),
POSTs the same FormData via fetch with
Accept: application/json, and shows the server’s own
message inline in an aria-live="polite" status line instead
of redirecting. It’s a thinner rendering of the same response, not a
second implementation — with two fallback strings hardcoded in the JS
for the case where no JSON comes back at all, which is the one
duplication the split costs.
Two details in that script exist because of specifics elsewhere in
the stack, not as generic form polish. First, on success the script
calls form.reset(), adds an is-subscribed
class, and moves focus to the status line rather than leaving an emptied
input and a re-armed submit button sitting there — an empty form with
nothing else changed reads as “did that even work” to a screen reader
user and a sighted one alike. Second, on any non-success path it calls
window.turnstile.reset() on the widget, because a
Turnstile token is single-use — a replayed one comes back
timeout-or-duplicate: a retry that resubmits the same spent
token fails every time regardless of whether the underlying problem was
a bad email address or a Kit outage, so the reset has to happen on every
failure branch, not just the ones that are actually Turnstile’s
fault.
What this bought, and what it didn’t
No database also means no export, no query, no “how many subscribers do we have” without opening Kit’s own dashboard — this app has zero visibility into that number by design, and the privacy page discloses Kit as the processor for exactly that reason: the data doesn’t stop at this app, it lives with a third party from the first write. That’s a real trade, not a free lunch. What it bought back is a signup feature with no migration, no schema, no backup story, and no new failure mode for the database this site was never going to add just to hold a mailing list.
The bigger pattern is the one worth taking away: before reaching for a table, ask what’s actually the system of record for the thing you’re storing. Sometimes it’s genuinely your app. Sometimes — a mailing list, a support queue, a payments ledger — a specialized third party already is the system of record, and the correct amount of code in your app is the smallest client that gets data there reliably and tells the truth about whether it arrived. The same no-database constraint decides how this site schedules posts, which I wrote up in scheduled posts in Phoenix with no database.
If you’re weighing a similar build-vs-lean-on-a-vendor call on your own Elixir stack, or want a second pass on where a feature like this can quietly fail, let’s talk.