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
  1. Where do subscribers live, if not in a table?
  2. The two-call shape, and why the referrer matters
  3. What the controller checks before Kit ever sees an email
  4. Fail closed here, fail open there
  5. Ship dark, flip live: the two-env-var flag
  6. Reusing the contact form’s guardrails, not rebuilding them
  7. No JS, and then with JS
  8. 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
end

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