How Helen Gets Made A making-of, not the live show Watch her live

Treat · the whole path, in code

From a card tap to a relay click: the whole treat path, verified

Someone on a phone pays two dollars. About a second later a relay in the sitting room closes across the treat remote's button pads, and a treat drops in front of Treat Cam.

Every hop between the card and the click, verbatim: the PaymentIntent, the signature check, the idempotent dispense, the Home Assistant automation, and the relay. The relay end, the part with a soldering iron, is on the treat entry page.

Treat Cam: the dispenser corner where a paid treat lands
The last hop is a relay closing for a moment. Everything before it is making sure the relay only closes when Stripe says money moved.

The server side is one file, server/api.mjs, 666 lines of plain node:http. No Express, no Stripe SDK, no Home Assistant library. It talks to Stripe with fetch and form-encoded bodies, verifies webhooks with node:crypto, and talks to Home Assistant with a single POST. It is small enough to hold in your head, and it has been dropping real treats since the on-domain checkout went live. Where this server runs, and how it is deployed, is on the hosting page; the reasoning without the code is on the treat entry page.

The path, hop by hop

What happens between the tap and the drop
browser (helenthecatlive.com)
   |  POST /api/feed/intent                      origin-checked, 6/min/IP, 503 if not ready, 429 if cap hit
   v
api.mjs  -->  POST https://api.stripe.com/v1/payment_intents
   |          amount=200  metadata[kind]=treat  automatic_payment_methods[enabled]=true
   |  <-- clientSecret
   v
Stripe Elements confirms the card on the page (never on this server)
   |
   |  Stripe -->  POST /api/webhooks/stripe   (Stripe-Signature: t=..., v1=...)
   v
verifyStripeSignature()   HMAC-SHA256 over "t.rawBody", 5-minute window, timing-safe compare
   |
   |  event.type == payment_intent.succeeded && metadata.kind == treat
   v
dispenseTreat()   stamp file per intent (idempotent) -> daily cap -> cooldown -> one POST
   |
   |  POST http://127.0.0.1:8472/api/webhook/<HA_WEBHOOK_ID>    body "{}"  timeout 8 s
   v
socat (ha-chain.service)  --  Tailscale  -->  Home Assistant :8123 on the Windows box at home
   |
   v
automation helen_treat_dispense  -->  switch.turn_on  switch.cat_treat_dispenser_sonoff_<SONOFF_DEVICE_ID>_1
   |
   v
SonoffLAN  -->  relay channel 1 closes across the remote's treat-button pads  -->  treat drops
   |
   v
success page polls GET /api/feed/status?payment_intent=pi_...  until {dispensed:true}

Two things about the shape before the code. First, the payment and the dispense are decoupled by the webhook: the page that took the card never tells the server "I paid". Stripe does, with a signature. Second, the server that takes money is on a small VPS with nothing else on it; the relay is on a home network behind Tailscale. The only thing that crosses from one to the other is one POST to one URL, and that URL is the credential.

Configuration, names only

Environment the server reads at start (sourced into the shell that starts pm2, see the gotcha below)
VariableMeaning
STRIPE_SECRET_KEYServer-side key for creating PaymentIntents. A restricted key with only PaymentIntent write is enough.
STRIPE_PUBLISHABLE_KEYHanded to the page for Stripe Elements.
STRIPE_WEBHOOK_SECRETThe whsec_… for this one endpoint. Per endpoint, not per account.
HA_FEED_WEBHOOK_URLhttp://127.0.0.1:8472/api/webhook/<HA_WEBHOOK_ID> — loopback, because a socat unit carries it over Tailscale.
TREAT_PRICE_CENTSDefault 200. Floor 50. The client never sends a price.
TREAT_DAILY_CAPDefault 120 dispenses per UTC day.
TREAT_COOLDOWN_SECONDSDefault 8, floor 4. Minimum gap between two relay clicks.
server/api.mjs — the treat constants
const HA_FEED_WEBHOOK_URL = process.env.HA_FEED_WEBHOOK_URL || "";
const TREAT_CENTS = Math.max(50, Number(process.env.TREAT_PRICE_CENTS || 200));
const TREAT_DAILY_CAP = Math.max(1, Number(process.env.TREAT_DAILY_CAP || 120));
const TREAT_COOLDOWN_SECONDS = Math.max(4, Number(process.env.TREAT_COOLDOWN_SECONDS || 8));
const feedReady = Boolean(STRIPE_SECRET && STRIPE_PK && HA_FEED_WEBHOOK_URL);

feedReady is what /api/feed/stats reports. If any of the three is missing the treat button on the site shows as closed rather than taking money it cannot honour.

1. Creating the PaymentIntent

The Stripe call is a fetch with a form body. That is all the SDK does for this endpoint, and not pulling it in keeps the server dependency-free.

server/api.mjs — stripe() and POST /api/feed/intent
async function stripe(path, body) {
  if (!STRIPE_SECRET) {
    const err = new Error("Secure checkout is not configured.");
    err.status = 503;
    throw err;
  }
  const res = await fetch(`https://api.stripe.com${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${STRIPE_SECRET}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams(body),
  });
  const data = await res.json();
  if (!res.ok) {
    const err = new Error(data.error?.message || "Stripe error");
    err.status = 400;
    throw err;
  }
  return data;
}

// inside handleApi(req, res, url):
if (req.method === "POST" && url.pathname === "/api/feed/intent") {
  if (!originAllowed(req) || !isJson(req)) return send(403, { message: "Bad origin." });
  if (!rateLimit(`treat:${clientIp(req)}`, 6, 60 * 1000)) return send(429, { message: "The petition chamber is busy." });
  if (!feedReady) return send(503, { message: "The treat bowl is not open yet." });
  const stats = treatStats();
  if (stats.today >= TREAT_DAILY_CAP) return send(429, { message: "The bowl reopens tomorrow." });
  const body = await readJson(req);
  const email = String(body.email || "").trim();
  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return send(400, { message: "That email does not look right." });
  const intent = await stripe("/v1/payment_intents", {
    amount: String(TREAT_CENTS),
    currency: "usd",
    "automatic_payment_methods[enabled]": "true",
    ...(email ? { receipt_email: email } : {}),
    "metadata[store]": "helenthecatlive",
    "metadata[kind]": "treat",
  });
  return send(200, { clientSecret: intent.client_secret, publishableKey: STRIPE_PK, amount: TREAT_CENTS });
}

Notice what the request body is allowed to contain: an optional email. Not a price, not a quantity, not a product. amount comes from the server's own constant. metadata[kind]=treat is how the webhook later knows this intent means "click the relay" and not "send a mug to the printer" — the same endpoint handles both kinds.

2. Verifying the webhook signature without the SDK

Stripe signs each webhook with an HMAC over {timestamp}.{raw body} using the endpoint's whsec_… secret, and sends it as Stripe-Signature: t=…,v1=…. The check is short enough that there is no reason to ship a library for it. The body must be the raw bytes; parse JSON only after the check passes.

server/api.mjs — verifyStripeSignature()
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyStripeSignature(raw, header, secret) {
  const parts = Object.fromEntries(
    String(header)
      .split(",")
      .map((piece) => {
        const i = piece.indexOf("=");
        return i === -1 ? ["", ""] : [piece.slice(0, i).trim(), piece.slice(i + 1).trim()];
      }),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  const age = Math.abs(Date.now() / 1000 - Number(t));
  if (!Number.isFinite(age) || age > 300) return false;
  const payload = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw));
  const expected = createHmac("sha256", secret).update(`${t}.`).update(payload).digest("hex");
  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
  • The secret is the whole whsec_… string, prefix included. That is what Stripe's own libraries do; stripping the prefix is the most common way to get a check that never passes.
  • Five-minute window. A replayed webhook from yesterday fails on age before the HMAC is even computed.
  • timingSafeEqual on equal-length buffers, so a wrong signature takes the same time to reject as a nearly-right one.
  • Only v1 is checked. Stripe may send several v1 values during a secret rotation; this takes the last one parsed. If you rotate secrets, do it with a short overlap and watch the log.
server/api.mjs — readRaw() and POST /api/webhooks/stripe
function readRaw(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let n = 0;
    req.on("data", (c) => {
      n += c.length;
      if (n > 64_000) {
        reject(Object.assign(new Error("too large"), { status: 413 }));
        req.destroy();
        return;
      }
      chunks.push(c);
    });
    req.on("end", () => resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

if (req.method === "POST" && url.pathname === "/api/webhooks/stripe") {
  const raw = await readRaw(req);
  if (!STRIPE_WEBHOOK_SECRET) return send(503, { message: "Webhook is not configured." });
  if (!verifyStripeSignature(raw, String(req.headers["stripe-signature"] || ""), STRIPE_WEBHOOK_SECRET)) {
    return send(400, { message: "Invalid webhook." });
  }
  let event;
  try {
    event = JSON.parse(Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw));
  } catch {
    return send(400, { message: "Invalid webhook." });
  }
  const intent = event.data?.object;
  if (event.type === "payment_intent.succeeded" && intent?.id) {
    const ok = await fulfillPaid(intent);
    if (!ok) return send(500, { message: "Fulfillment failed." });
  }
  return send(200, { received: true });
}

async function fulfillPaid(intent) {
  if (intent?.metadata?.kind === "treat") return dispenseTreat(intent);
  // ... merch goes to the print-on-demand path ...
}

The 64 kB cap on the raw body is there because this is a public URL and a PaymentIntent event is about 3 kB. For the treat kind, dispenseTreat always returns true, so Stripe gets a 200 as soon as the signature verifies and never retries a treat, because a retry an hour later would drop a treat nobody is watching for. Fulfilment problems are recorded, not retried.

3. The dispense: idempotent, capped, spaced

Stripe delivers webhooks at least once. The relay must click at most once per payment. The bridge between those two is a stamp file named after the PaymentIntent id, written before the POST, so a duplicate delivery finds the stamp and returns.

server/api.mjs — treatStats() and dispenseTreat()
function treatLogPath() {
  const dir = join(root, ".runtime");
  mkdirSync(dir, { recursive: true });
  return join(dir, "treat-events.ndjson");
}

function treatStats() {
  const path = treatLogPath();
  if (!existsSync(path)) return { today: 0, lastAt: 0 };
  const day = new Date().toISOString().slice(0, 10);
  let today = 0;
  let lastAt = 0;
  for (const line of readFileSync(path, "utf8").split("\n")) {
    if (!line.trim()) continue;
    try {
      const row = JSON.parse(line);
      if (row.dispensed && String(row.ts || "").startsWith(day)) today += 1;
      const t = Date.parse(row.ts || "");
      if (row.dispensed && t > lastAt) lastAt = t;
    } catch {
      /* skip */
    }
  }
  return { today, lastAt };
}

async function dispenseTreat(intent) {
  const dir = join(root, ".runtime");
  mkdirSync(dir, { recursive: true });
  const stamp = join(dir, `treat-${intent.id}.json`);
  if (existsSync(stamp)) return true;                       // already handled: idempotent
  if (!HA_FEED_WEBHOOK_URL) {
    appendFileSync(treatLogPath(), JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: false, reason: "no-ha" }) + "\n");
    return true;
  }
  const stats = treatStats();
  if (stats.today >= TREAT_DAILY_CAP) {                     // spend cap: the relay has a daily budget
    appendFileSync(treatLogPath(), JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: false, reason: "cap" }) + "\n");
    writeFileSync(stamp, JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: false, reason: "cap" }));
    return true;
  }
  writeFileSync(stamp, JSON.stringify({ id: intent.id, ts: new Date().toISOString(), pending: true }));
  const wait = Math.max(0, TREAT_COOLDOWN_SECONDS * 1000 - (Date.now() - stats.lastAt));   // cooldown
  const fire = async () => {
    try {
      const res = await fetch(HA_FEED_WEBHOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: "{}",
        signal: AbortSignal.timeout(8000),
      });
      const ok = res.ok;
      appendFileSync(treatLogPath(), JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: ok }) + "\n");
      writeFileSync(stamp, JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: ok }));
    } catch {
      appendFileSync(treatLogPath(), JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: false, reason: "ha-fail" }) + "\n");
      writeFileSync(stamp, JSON.stringify({ id: intent.id, ts: new Date().toISOString(), dispensed: false, reason: "ha-fail" }));
    }
  };
  if (wait > 0) setTimeout(() => void fire(), wait);
  else await fire();
  return true;
}
What each guard is for
GuardWhat goes wrong without it
Stamp file per pi_…Stripe redelivers (it does, on any non-2xx or slow reply) and one payment becomes two treats.
Daily cap from the NDJSON ledgerA generous evening, or a script, empties the hopper by midnight. 120 a day is more than the dispenser holds; the cap is a ceiling on embarrassment, not a business rule.
Cooldown relative to lastAtTwo payments in the same second send two clicks before the mechanism has cycled; the second is lost, and the buyer sees nothing drop. Eight seconds is longer than the drop takes.
AbortSignal.timeout(8000)Tailscale or the home box is down, the webhook handler hangs, Stripe times out at 30 s and redelivers, and now the stamp says pending forever. With the timeout the ledger gets ha-fail and a human can see it.
Ledger lines even on failureMoney was taken. If nothing dropped there must be a row saying why, or you are keeping two dollars for a click that never happened.

The .runtime/ directory is a symlink to a path outside the release directory, so stamps and the ledger survive a deploy (see the release layout).

4. What the success page polls

server/api.mjs — GET /api/feed/stats and GET /api/feed/status
if (req.method === "GET" && url.pathname === "/api/feed/stats") {
  const stats = treatStats();
  return send(200, {
    feedReady,
    treatCents: TREAT_CENTS,
    remaining: Math.max(0, TREAT_DAILY_CAP - stats.today),
  });
}
if (req.method === "GET" && url.pathname === "/api/feed/status") {
  const id = url.searchParams.get("payment_intent") || "";
  if (!/^pi_[A-Za-z0-9]+$/.test(id)) return send(400, { message: "Invalid payment." });
  const stamp = join(root, ".runtime", `treat-${id}.json`);
  const stats = treatStats();
  if (!existsSync(stamp)) {
    return send(200, { dispensed: false, pending: true, remaining: Math.max(0, TREAT_DAILY_CAP - stats.today) });
  }
  let row = {};
  try { row = JSON.parse(readFileSync(stamp, "utf8")); } catch { row = {}; }
  return send(200, {
    dispensed: Boolean(row.dispensed),
    pending: Boolean(row.pending) && row.dispensed !== true,
    remaining: Math.max(0, TREAT_DAILY_CAP - stats.today),
    reason: row.reason || "",
  });
}

The regex on the id is the whole input validation: it keeps the stamp path inside .runtime/ and nothing else. The success page polls this every couple of seconds and turns "Petition received" into "A treat has dropped" when dispensed flips.

5. The Home Assistant side

Home Assistant runs in Docker on the same Windows desktop as Frigate (see the Windows setup). The automation is a webhook trigger and one action.

automations.yaml
- id: helen_treat_dispense
  alias: Helen - dispense a treat (paid)
  description: Webhook from the helenthecatlive server, via the VPS ha-chain tunnel.
  triggers:
  - platform: webhook
    webhook_id: <HA_WEBHOOK_ID>
    local_only: false
    allowed_methods:
    - POST
    - PUT
  conditions: []
  actions:
  - target:
      entity_id: switch.cat_treat_dispenser_sonoff_<SONOFF_DEVICE_ID>_1
    action: switch.turn_on
  mode: queued
  • webhook_id is a secret. Home Assistant webhooks have no other authentication; whoever knows the id can fire the automation. Make it long and random, and never put the URL in a page, a log line, or a scheduled-task definition. Ours reaches HA only from a loopback tunnel on the VPS, so it is never on the public internet at all.
  • local_only: false because the request arrives from a Tailscale address, not the LAN.
  • mode: queued so two webhooks a few seconds apart both run, in order, instead of the second being dropped (single) or the two overlapping (parallel). The server's cooldown already spaces them; this is the second layer.
  • Only turn_on. There is no turn_off action. The relay channel is set to inching (pulse) mode on the device itself, so it releases on its own after a short interval. Set the inching interval in the eWeLink app to roughly the length of a thumb press and test with the physical remote button still working afterwards. We did not record the exact width we settled on; start short and lengthen until the dispenser registers every press.

The relay is a Sonoff four-channel eWeLink board, reached from Home Assistant through the SonoffLAN custom component, which talks to the device over the LAN rather than the cloud. HA exposes each channel as a switch, and this automation only ever touches channel 1. The dry-contact side of that channel is what is soldered across the remote's button pads — that part is the whole story on the entry page. The trigger docs are at home-assistant.io.

6. How the VPS reaches a box at home

The server posts to 127.0.0.1:8472. A four-line systemd unit turns that loopback port into a Tailscale connection to Home Assistant, so the Node process never needs to know a Tailscale address and never opens a socket to anything but localhost.

/etc/systemd/system/ha-chain.service (on the VPS)
[Unit]
Description=Home Assistant chain to the home box (127.0.0.1:8472 -> <TAILSCALE_IP>:8123 via tailscale)
After=network-online.target tailscaled.service
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:8472,bind=127.0.0.1,fork,reuseaddr TCP:<TAILSCALE_IP>:8123
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Home Assistant's port 8123 is never forwarded on the home router. The Windows side runs WSL2 with mirrored networking, which is what makes the container's port reachable at the machine's Tailscale address. If Tailscale is down, socat gets a connection refused, the fetch fails inside its 8-second timeout, the ledger gets ha-fail, and the success page keeps saying pending.

The checkout rule a real exploit taught us

Before this server existed, a different storefront of ours had a checkout that trusted the price the browser sent. That number can be edited in the request before it leaves the page, and it was. It was caught, and it is why every checkout we have written since follows four rules that are worth more than the rest of this page:

  1. Never trust a client-supplied price. Re-derive it on the server from what you sell. Here amount is a constant; for the merch checkout in the same file, moneyItems() looks every line up in the catalog by slug and variant id and sums the server's own prices. The request body carries ids and quantities, never dollars.
  2. Re-check availability before charging, and fail closed. The treat path checks feedReady and the daily cap before creating the intent, and checks the cap again in the webhook. If the check itself cannot run, the answer is "no", not "probably".
  3. Normalise anything that becomes a key. A stray dot, a trailing slash, mixed case — if two spellings of one thing can reach fulfilment, somebody will pay for the cheap spelling and receive the expensive one. The pi_… regex above is the small version of this rule.
  4. Mark fulfilled only on webhook success. Never on the client's say-so, never on "the intent was created". The stamp is written by the webhook handler after the signature verifies, and the ledger says what happened to each payment.

Gotchas

  • api.mjs does not read .env, and pm2 has no --env-file. A plain pm2 restart starts the server with no Stripe keys and feedReady: false, silently. The deploy script sources the env into the shell and starts pm2 with --update-env. Details on the hosting page.
  • Test with a signed synthetic event, not a card. Build a payment_intent.succeeded body with metadata.kind=treat, sign it with the same HMAC and the real secret, POST it to the webhook, and watch for a treat and a dispensed:true row. That is how the path was verified end to end.
  • AI’s partVerify before the redirect, not after. The synthetic event was fired by a coding assistant on 2026-08-20, the night the show moved onto its own domain, and the check earned its keep before it passed: the release had no STRIPE_WEBHOOK_SECRET in its environment and Stripe had no endpoint registered for the new domain, so the site would have taken two dollars and dropped nothing. Both were fixed, the signed event returned 200, the ledger logged dispensed:true, the counter went 120 to 119 and a treat fell, and only then was the old domain redirected.
  • Keep old paths from lying. An earlier version served the counter at /api/feed-stats; it is /api/feed/stats now. Anything that still polls the old path gets a 404 and thinks the bowl is closed. Check every consumer when you rename an endpoint.
  • The relay must not be a 120-volt switch. The remote is a coin-cell circuit. The channel wired to it is the dry-contact side of the relay, not mains. The entry page says this three times because people skip it.