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
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
| Variable | Meaning |
|---|---|
STRIPE_SECRET_KEY | Server-side key for creating PaymentIntents. A restricted key with only PaymentIntent write is enough. |
STRIPE_PUBLISHABLE_KEY | Handed to the page for Stripe Elements. |
STRIPE_WEBHOOK_SECRET | The whsec_… for this one endpoint. Per endpoint, not per account. |
HA_FEED_WEBHOOK_URL | http://127.0.0.1:8472/api/webhook/<HA_WEBHOOK_ID> — loopback, because a socat unit carries it over Tailscale. |
TREAT_PRICE_CENTS | Default 200. Floor 50. The client never sends a price. |
TREAT_DAILY_CAP | Default 120 dispenses per UTC day. |
TREAT_COOLDOWN_SECONDS | Default 8, floor 4. Minimum gap between two relay clicks. |
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.
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.
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
agebefore the HMAC is even computed. timingSafeEqualon equal-length buffers, so a wrong signature takes the same time to reject as a nearly-right one.- Only
v1is checked. Stripe may send severalv1values during a secret rotation; this takes the last one parsed. If you rotate secrets, do it with a short overlap and watch the log.
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.
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;
}
| Guard | What 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 ledger | A 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 lastAt | Two 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 failure | Money 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
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.
- 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_idis 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: falsebecause the request arrives from a Tailscale address, not the LAN.mode: queuedso 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 noturn_offaction. 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.
[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:
- Never trust a client-supplied price. Re-derive it on the server from what you sell. Here
amountis 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. - Re-check availability before charging, and fail closed. The treat path checks
feedReadyand 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". - 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. - 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.mjsdoes not read.env, and pm2 has no--env-file. A plainpm2 restartstarts the server with no Stripe keys andfeedReady: 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.succeededbody withmetadata.kind=treat, sign it with the same HMAC and the real secret, POST it to the webhook, and watch for a treat and adispensed:truerow. 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_SECRETin 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 returned200, the ledger loggeddispensed: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/statsnow. 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.