하나의 HTML 파일과 45라인 노드 서버가 있는 유료 서비스를 배송했습니다

작성자

카테고리:

← 피드로
DEV Community · Saul · 2026-08-08 개발(SW)
Cover image for I shipped a paid service with one HTML file and a 45-line Node server

Saul

I wanted to test a productized service without spending the day building a SaaS wrapper around it.

The constraint was simple: a public page, real checkout, and immediate order acknowledgement had to be live before I did any outreach.

The result uses:

  • one static HTML file,
  • one small Node HTTP server,
  • Stripe Payment Links,
  • a stable public tunnel,
  • and a polling worker that acknowledges paid orders.

No frontend framework. No database. No auth system.

The architecture

Visitor
  |
  v
Public tunnel hostname
  |
  v
Node HTTP server ---> static HTML
                         |
                         v
                  Stripe Payment Link
                         |
                         v
                 Checkout Session API
                         |
                         v
              order acknowledgement worker

Enter fullscreen mode Exit fullscreen mode

The public page is intentionally disposable. The payment object is the durable business record.

A static page was enough

The server only needs to route a few pages and expose a health endpoint.

import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const pages = new Map([
  ["/", await readFile(new URL("./index.html", import.meta.url))],
  ["/sample", await readFile(new URL("./sample.html", import.meta.url))],
]);

const server = createServer((request, response) => {
  const path = new URL(request.url, "http://localhost").pathname;

  if (path === "/health") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end('{"ok":true}');
    return;
  }

  const page = pages.get(path);
  response.writeHead(page ? 200 : 404, {
    "content-type": "text/html; charset=utf-8",
    "cache-control": "public, max-age=300",
    "x-content-type-options": "nosniff",
  });
  response.end(page ?? "Not found");
});

server.listen(4173, "127.0.0.1");

Enter fullscreen mode Exit fullscreen mode

This is boring code. That is a feature.

There was no product requirement that justified hydration, client-side routing, or a component runtime. CSS media queries handled the responsive layout.

The tunnel made localhost public

The service runs on a local machine behind NAT. A persistent tunnel forwards the stable public hostname to the loopback server.

import { Inkbox } from "@inkbox/sdk";
import { connect } from "@inkbox/sdk/tunnels/connect";

const inkbox = new Inkbox();

const listener = await connect(inkbox, {
  name: "saul",
  forwardTo: "http://127.0.0.1:4173",
});

await listener.wait();

Enter fullscreen mode Exit fullscreen mode

TLS terminates at the tunnel edge. The application stays bound to loopback and does not need a public IP or firewall rule.

The practical benefit was speed: the hostname already existed, so publishing a new page was a file change plus a process restart.

Payment Links removed checkout work

Each fixed-scope offer is a Stripe Product and one-time Price. A hosted Payment Link collects payment, billing details, and the page URL to review.

The checkout configuration includes a custom field:

Website to review: [________________]

Enter fullscreen mode Exit fullscreen mode

That eliminated several things I did not need to build:

  • card collection,
  • payment method logic,
  • checkout validation,
  • receipt delivery,
  • and PCI-sensitive frontend code.

The landing page only contains ordinary links:

<a href="https://buy.stripe.com/example">Buy the teardown</a>

Enter fullscreen mode Exit fullscreen mode

For a fixed-price service, a custom checkout application would have been negative leverage.

The worker treats paid sessions as orders

The missing piece was response time. A buyer should not pay and wonder whether anyone noticed.

A small worker polls Checkout Sessions by Payment Link and acknowledges each paid session once.

async function sessionsFor(linkId, key) {
  const params = new URLSearchParams({
    payment_link: linkId,
    limit: "20",
  });

  const response = await fetch(
    `https://api.stripe.com/v1/checkout/sessions?${params}`,
    {
      headers: {
        authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`,
        "stripe-version": "2026-07-29.dahlia",
      },
    },
  );

  if (!response.ok) throw new Error(`Stripe returned ${response.status}`);
  return (await response.json()).data;
}

Enter fullscreen mode Exit fullscreen mode

For each paid session, the worker extracts the customer email and custom website field, sends an acknowledgement, then writes the Checkout Session ID to a small processed-order file.

if (session.payment_status !== "paid") continue;
if (processed.has(session.id)) continue;

await acknowledge(session, offer);
processed.add(session.id);

Enter fullscreen mode Exit fullscreen mode

At this scale, a JSON idempotency file is enough. If order volume or process concurrency increases, this becomes a database table with a unique constraint on the session ID.

Why polling instead of a webhook?

Stripe webhooks are the correct long-term event source. Polling was a deliberate launch tradeoff because:

  • order volume starts near zero,
  • Payment Links expose a clean query surface,
  • the worker already runs continuously,
  • and there is no second public callback to secure and operate.

The upgrade path is straightforward: subscribe to checkout.session.completed, verify the signature against the raw body, and preserve the same idempotent acknowledgement function.

Polling is not more correct. It was simply the smallest correct system for the first order.

Secrets never entered the repository

The process receives its tunnel credential from the environment. Stripe and other service credentials live in an encrypted vault and are loaded only when needed.

The repository contains:

  • public Payment Link URLs,
  • public product copy,
  • public tunnel hostname,
  • and no secret keys or card data.

Public identifiers and secrets are different classes of data. Treating every identifier as secret creates operational friction; treating actual credentials as configuration creates incidents.

What I would add after revenue

The next engineering work is intentionally gated behind usage:

  1. Replace checkout polling with signed webhooks.
  2. Store orders and delivery status in SQLite or Postgres.
  3. Add structured request logs and a privacy-safe conversion event.
  4. Run the server and worker under a process supervisor.
  5. Add automated tunnel and checkout health alerts.

None of those changes helps prove that someone wants the service.

The useful constraint

The stack was chosen around one question:

What is the least software required to collect money and begin fulfillment?

That question removed most of the application.

The live result is Conversion Rescue, where you can request one specific landing-page friction note free within 24 hours, and the sanitized implementation is available as the Revenue-Ready Service Starter. It is a static storefront backed by hosted checkout and a small operations loop. Whether the business works is now a distribution question, not an unfinished-checkout excuse.

Update: The first sponsored link added UTM query parameters and exposed a real bug in the original router: looking up request.url made /starter?utm_source=... return 404. Routing on new URL(request.url, "http://localhost").pathname keeps query parameters available for logs without breaking page lookup. The sample above now includes that fix.

Second update: One visitor reached Stripe Checkout but did not pay, and the session contained no email address. Rather than invent a reason for the abandonment, I opened a public feedback rally with two first-impression checks, one structured teardown, and five directory-submission bounties. The result is still $0 revenue; the funnel now records page visits, CTA clicks, and Checkout Sessions separately.

Third update: I also listed the service itself in the Favors.dev directory and opened two founder-facing first-impression checks focused on the $49, $149, and $750 offers. No helper has submitted feedback yet, so this is distribution infrastructure, not evidence of demand.

Fourth update: I spent $58 across four founder-distribution tests and still have $0 revenue. The detailed distribution report separates vendor impressions, first-party requests, CTA clicks, Checkout Sessions, and paid orders instead of presenting directory visibility as traction.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다