24 Jan 2026

Adding a serverless backend to Hugo: How I built payment flows without losing static site speed

You know how it goes. You build a Hugo site, it’s blazing fast, and then you add a services page. Now you need payments. Webhooks. Email confirmations. Suddenly your “static site” needs a backend.

I run vitthalmirji.com on Hugo. For a while I used pre-created PayU/Cashfree links stored in YAML. It worked, but it was slow and error‑prone. Every new service meant logging into dashboards, creating links manually, and copying URLs.

I wanted a real flow:

  • create links dynamically,
  • accept webhooks safely,
  • store transaction history for accounting,
  • keep the static site fast.

This post is how I did it without turning Hugo into a server app.


Why Hugo can’t do this

Hugo is a static site generator. It renders Markdown into HTML at build time. When a user visits your site, they’re just getting cached HTML files from a CDN. There’s no long‑running process that can call a payment API or accept webhooks.

That’s why Hugo sites are fast. But it’s also why they can’t:

  • run server-side logic at request time,
  • receive payment gateway callbacks,
  • store anything dynamic.

So the real question is not “how do I make Hugo do payments?” It’s: how do I add a backend without ruining the static site?


The options (and why most don’t work)

I looked at four ways to “bolt on” a backend.

Option 1: Build-time data fetching

Hugo can fetch APIs at build time with resources.GetRemote:

1
2
3
4
5
{{ $data := resources.GetRemote "https://api.example.com/products" }}
{{ $json := $data | transform.Unmarshal }}
{{ range $json.items }}
  <div>{{ .name }}</div>
{{ end }}

What’s happening:

  • API fetch happens during hugo build.
  • Data is baked into static HTML.
  • Page loads never hit the API.

This works for content that changes daily. It fails for payment links that need to be created per customer and webhooks that arrive after the build.

Option 2: Third‑party form services

Formspree, Netlify Forms, and friends are fine for a contact form. They aren’t payment backends. No webhook signature validation, no state machine, no transaction audit trail.

Option 3: A full backend server

Yes, you can run a Go/Node server. But for a personal services page, it’s overkill:

  • patching and security updates,
  • scaling and health checks,
  • long‑running processes,
  • infrastructure overhead for a tiny API.

Option 4: Serverless functions

Serverless functions are the sweet spot. You write a few endpoints, deploy them, and let the platform run them on demand.

This is where Cloudflare Workers fit best for me: same vendor as my CDN/DNS, low cold‑start time, and easy routing from /api/*.


The approach I settled on

I kept the static site as‑is, and added a tiny backend that does only the dynamic parts. Two deployments, one repo, routed together at the edge.

Why this matters
The static site and the backend are separate deploys. If the backend fails, the site still loads. Payments degrade, not the whole site.

The architecture (two parts, one codebase)

flowchart TB
    subgraph BROWSER["User’s browser"]
        USER["User visits site"]
    end

    subgraph EDGE["Cloudflare edge"]
        ROUTER["Route by path"]
    end

    subgraph STATIC["Static site (GitHub Pages)"]
        HTML["Hugo HTML + CSS + JS"]
    end

    subgraph API["Workers backend"]
        WORKER["/api/* endpoints"]
    end

    subgraph STORAGE["Cloudflare storage"]
        D1["D1 (SQLite)"]
        R2["R2 (files)"]
    end

    subgraph EXTERNAL["External services"]
        PAYU["PayU"]
        CASHFREE["Cashfree"]
        RESEND["Resend email"]
    end

    USER --> ROUTER
    ROUTER -->|" vitthalmirji.com/* "| HTML
    ROUTER -->|" vitthalmirji.com/api/* "| WORKER
    WORKER --> D1
    WORKER --> R2
    WORKER --> PAYU
    WORKER --> CASHFREE
    WORKER --> RESEND
    style USER fill: #e1f5ff, stroke: #0066cc, color: #000
    style ROUTER fill: #fff4e1, stroke: #cc8800, color: #000
    style HTML fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style WORKER fill: #f0e1ff, stroke: #8800cc, color: #000
    style D1 fill: #e1f5ff, stroke: #0066cc, color: #000
    style R2 fill: #e1f5ff, stroke: #0066cc, color: #000
    style PAYU fill: #ffe1e1, stroke: #cc0000, color: #000
    style CASHFREE fill: #ffe1e1, stroke: #cc0000, color: #000
    style RESEND fill: #ffe1e1, stroke: #cc0000, color: #000

Static stays on GitHub Pages. Dynamic calls go to Workers. Cloudflare routes the two behind the same domain.


Why I chose Cloudflare Workers

I was already using Cloudflare for DNS and CDN proxy, so Workers gave me:

  • No new vendor for hosting.
  • Simple routing from /api/*.
  • Edge execution close to users.
  • V8 isolate cold starts that feel instant.

Here’s the quick comparison I used:

PlatformCold startOperational overheadFit for tiny APIs
Cloudflare WorkersVery lowLowExcellent
Netlify FunctionsMediumLowGood
Vercel FunctionsMediumLowGood
AWS LambdaVariableHighOverkill for small sites

If a user is about to pay, I want the gateway call to be the slow part, not my runtime waking up.


The backend surface area

I forced myself to keep this tiny. The API is six endpoints.

EndpointMethodCallerProtectionPurpose
/api/healthGETAnyoneNoneHealth + DB init guard
/api/payment/create-linkPOSTBrowserRate limiting (edge)Create PayU/Cashfree link
/api/card/verify/sessionPOSTBrowserRate limiting (edge)₹1 card verification session
/api/contact/submitPOSTBrowserRate limiting (edge)Contact form
/api/payment/webhook/payuPOSTPayUSignaturePayment confirmation
/api/payment/webhook/cashfreePOSTCashfreeSignaturePayment confirmation + card verify

That’s the full backend. No user auth, no carts, no subscriptions. Just what the site needs.


Why I used Effect TypeScript

Traditional serverless code hides errors inside try/catch. That’s risky for payment flows.

Effect makes error types explicit and forces you to handle them. This matters when failures mean lost money.

The contrast

Promise‑style:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
async function createPaymentLink(request: Request) {
  try {
    const body = await request.json();
    const link = await callGatewayAPI(body);
    await saveToDatabase(link);
    return Response.json({ url: link });
  } catch (error) {
    return new Response("Error", { status: 500 });
  }
}

Effect‑style:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const program: Effect.Effect<PaymentLink, DatabaseError | GatewayError, DatabaseService> =
  Effect.gen(function* () {
    const db = yield* DatabaseService;
    const validation = yield* ValidationService;

    const validatedBody = yield* validation.validateRequest(body);
    const link = yield* createGatewayLink(validatedBody);
    yield* db.save(link);

    return link;
  });

What’s happening:

  • The type signature lists possible errors.
  • The compiler forces you to handle all of them.
  • Dependencies are injected via Context.Tag.

This is boring in the best way. Payments shouldn’t be “hope and pray” code.

Details
Promises make it easy to ignore error cases and to pass dependencies by hand. Effect makes both explicit: errors in the type system, and services in the runtime layer. For a payment flow, that discipline is worth the extra setup.

The database: auto‑init with a guard

I still use CREATE TABLE IF NOT EXISTS, but I don’t run it on every request anymore. I added a one‑time guard so only the first request in an isolate runs schema setup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
let initDone = false;
let initInFlight: Promise<void> | null = null;

const initializeOnce = (db: D1Database): Effect.Effect<void, DatabaseError> =>
  Effect.tryPromise({
    try: async () => {
      if (initDone) return;
      if (!initInFlight) {
        initInFlight = Effect.runPromise(initialize(db));
      }
      await initInFlight;
      initDone = true;
    },
    catch: (error) => new DatabaseError({ message: "Init failed", cause: error }),
  });

What’s happening:

  • First request triggers DB setup.
  • Concurrent requests wait on the same promise.
  • Later requests skip it entirely.

This keeps the “self‑healing” property without paying the cost on every call.

CREATE TABLE IF NOT EXISTS is not a migration system
This trick only helps for the initial schema. If you add a new column later, CREATE TABLE IF NOT EXISTS won’t magically backfill it. For schema evolution, you still want real D1 migrations (ALTER TABLE, backfills, indexes).
Details
1
2
# Example: apply a migration file (replace with your DB name / env)
npx -y wrangler d1 execute <db-name> --file workers/migrations/0002_add_expires_at.sql --env production

The schema I ended up with

At minimum, I need these tables:

  • transactions for payment links + status
  • webhook_events for idempotency and audit trail
  • services for service definitions and pricing
  • contact_submissions for the contact form
  • card_verifications for ₹1 pre‑auth + void tracking

That last table is important. Card verification is a separate flow and deserves its own audit trail.

Schema snapshot (actual SQL)

Here’s the shape I’m actually running in D1 (trimmed to the essentials):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
CREATE TABLE IF NOT EXISTS transactions (
  id TEXT PRIMARY KEY,
  txn_id TEXT UNIQUE NOT NULL,
  service_key TEXT NOT NULL,
  amount INTEGER NOT NULL,
  currency TEXT DEFAULT 'INR',
  gateway TEXT NOT NULL,
  status TEXT NOT NULL,
  customer_email TEXT NOT NULL,
  customer_name TEXT,
  gateway_response TEXT,
  payment_url TEXT,
  idempotency_key TEXT UNIQUE,
  email_sent_at INTEGER,
  expires_at INTEGER,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS webhook_events (
  id TEXT PRIMARY KEY,
  event_id TEXT UNIQUE NOT NULL,
  gateway TEXT NOT NULL,
  txn_id TEXT,
  payload_hash TEXT NOT NULL,
  status TEXT,
  received_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS services (
  key TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  price_inr INTEGER NOT NULL,
  enabled BOOLEAN DEFAULT 1,
  created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS contact_submissions (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  message TEXT NOT NULL,
  ip_hash TEXT,
  created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS card_verifications (
  id TEXT PRIMARY KEY,
  order_id TEXT UNIQUE NOT NULL,
  gateway TEXT NOT NULL,
  amount INTEGER NOT NULL,
  currency TEXT DEFAULT 'INR',
  status TEXT NOT NULL,
  customer_email TEXT NOT NULL,
  customer_name TEXT,
  customer_phone TEXT NOT NULL,
  cf_payment_id TEXT,
  gateway_response TEXT,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  voided_at INTEGER
);

What’s happening here:

  • transactions is the core ledger for payment links + status.
  • webhook_events is my idempotency wall for gateway retries.
  • services lets the backend own pricing (not the frontend).
  • contact_submissions is a small audit trail (no spam loss).
  • card_verifications isolates ₹1 pre‑auth from real payments.
Details
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
-- Transactions
CREATE TABLE IF NOT EXISTS transactions (
  id TEXT PRIMARY KEY,
  txn_id TEXT UNIQUE NOT NULL,
  service_key TEXT NOT NULL,
  amount INTEGER NOT NULL,
  currency TEXT DEFAULT 'INR',
  gateway TEXT NOT NULL,
  status TEXT NOT NULL,
  customer_email TEXT NOT NULL,
  customer_name TEXT,
  gateway_response TEXT,
  payment_url TEXT,
  idempotency_key TEXT UNIQUE,
  email_sent_at INTEGER,
  expires_at INTEGER,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(status);
CREATE INDEX IF NOT EXISTS idx_transactions_created ON transactions(created_at);
CREATE INDEX IF NOT EXISTS idx_transactions_idempotency ON transactions(idempotency_key);
CREATE INDEX IF NOT EXISTS idx_transactions_txn_id ON transactions(txn_id);
CREATE INDEX IF NOT EXISTS idx_transactions_expires ON transactions(expires_at);

-- Webhook events
CREATE TABLE IF NOT EXISTS webhook_events (
  id TEXT PRIMARY KEY,
  event_id TEXT UNIQUE NOT NULL,
  gateway TEXT NOT NULL,
  txn_id TEXT,
  payload_hash TEXT NOT NULL,
  status TEXT,
  received_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_webhook_events_event_id ON webhook_events(event_id);
CREATE INDEX IF NOT EXISTS idx_webhook_events_txn_id ON webhook_events(txn_id);
CREATE INDEX IF NOT EXISTS idx_webhook_events_received ON webhook_events(received_at);

-- Contact submissions
CREATE TABLE IF NOT EXISTS contact_submissions (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  message TEXT NOT NULL,
  ip_hash TEXT,
  created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_contact_created ON contact_submissions(created_at);

-- Services
CREATE TABLE IF NOT EXISTS services (
  key TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  price_inr INTEGER NOT NULL,
  enabled BOOLEAN DEFAULT 1,
  created_at INTEGER NOT NULL
);

-- Card verification
CREATE TABLE IF NOT EXISTS card_verifications (
  id TEXT PRIMARY KEY,
  order_id TEXT UNIQUE NOT NULL,
  gateway TEXT NOT NULL,
  amount INTEGER NOT NULL,
  currency TEXT DEFAULT 'INR',
  status TEXT NOT NULL,
  customer_email TEXT NOT NULL,
  customer_name TEXT,
  customer_phone TEXT NOT NULL,
  cf_payment_id TEXT,
  gateway_response TEXT,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  voided_at INTEGER
);

CREATE INDEX IF NOT EXISTS idx_card_verifications_order_id ON card_verifications(order_id);
CREATE INDEX IF NOT EXISTS idx_card_verifications_status ON card_verifications(status);
CREATE INDEX IF NOT EXISTS idx_card_verifications_created ON card_verifications(created_at);

This is the exact schema and index set from the migrations (initial schema + later ALTER TABLE for expires_at). It’s long, but it’s the truth on disk.

The data model (visual)

erDiagram
    TRANSACTIONS {
        text id PK
        text txn_id UK
        text service_key
        integer amount
        text currency
        text gateway
        text status
        text customer_email
        text customer_name
        text gateway_response
        text payment_url
        text idempotency_key UK
        integer email_sent_at
        integer expires_at
        integer created_at
        integer updated_at
    }

    WEBHOOK_EVENTS {
        text id PK
        text event_id UK
        text gateway
        text txn_id
        text payload_hash
        text status
        integer received_at
    }

    SERVICES {
        text key PK
        text name
        text description
        integer price_inr
        boolean enabled
        integer created_at
    }

    CONTACT_SUBMISSIONS {
        text id PK
        text name
        text email
        text message
        text ip_hash
        integer created_at
    }

    CARD_VERIFICATIONS {
        text id PK
        text order_id UK
        text gateway
        integer amount
        text currency
        text status
        text customer_email
        text customer_name
        text customer_phone
        text cf_payment_id
        text gateway_response
        integer created_at
        integer updated_at
        integer voided_at
    }

    SERVICES ||--o{ TRANSACTIONS: priced_by
    TRANSACTIONS ||--o{ WEBHOOK_EVENTS: updated_by
    CARD_VERIFICATIONS ||--o{ WEBHOOK_EVENTS: audit

What’s happening here:

  • services is the source of truth for pricing.
  • transactions are payment link sessions.
  • expires_at makes link reuse deterministic (don’t hand out stale links).
  • webhook_events give me idempotency + audit.
  • card_verifications are isolated from real money flows.

This is the main endpoint the front‑end calls.

Two small UX details that matter more than they sound:

  • I set the link expiry to 15 minutes and also append a human hint like “expires in 15 minutes” in the gateway description/purpose.
  • I pass customer name/email/phone in the link creation request so the gateway page can prefill.
flowchart TD
    START["Browser submits /api/payment/create-link"] --> EDGE["Cloudflare rate limit (zone rule)"]
    EDGE --> VALIDATE["Validate input (name/email/phone)"]
    VALIDATE --> SERVICE["Load service + price from D1"]
    SERVICE --> IDEMPOTENT{"Idempotency key exists?"}
    IDEMPOTENT -->|Yes| CACHED["Return cached payment_url"]
    IDEMPOTENT -->|No| REUSE{"Recent unexpired link (5-15m)?"}
    REUSE -->|Yes| REUSED["Return recent payment_url"]
    REUSE -->|No| GATEWAY["Select gateway (PayU/Cashfree)"]
    GATEWAY --> LINK["Create link (expires in 15 min)"]
    LINK --> STORE["Store transaction (expires_at) in D1"]
    STORE --> RETURNURL["Set return URLs to /api/payment/return/*"]
    STORE --> RESP["Return payment_url + link_id"]
    style START fill: #e1f5ff, stroke: #0066cc, color: #000
    style EDGE fill: #fff4e1, stroke: #cc8800, color: #000
    style VALIDATE fill: #fff4e1, stroke: #cc8800, color: #000
    style IDEMPOTENT fill: #fff4e1, stroke: #cc8800, color: #000
    style CACHED fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style SERVICE fill: #f0e1ff, stroke: #8800cc, color: #000
    style REUSE fill: #fff4e1, stroke: #cc8800, color: #000
    style REUSED fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style GATEWAY fill: #f0e1ff, stroke: #8800cc, color: #000
    style LINK fill: #f0e1ff, stroke: #8800cc, color: #000
    style STORE fill: #e1f5ff, stroke: #0066cc, color: #000
    style RETURNURL fill: #f0e1ff, stroke: #8800cc, color: #000
    style RESP fill: #e1ffe1, stroke: #2d7a2d, color: #000

What’s happening here:

  • Validation happens before any gateway call.
  • Idempotency short‑circuits duplicates.
  • Only the last step returns the link.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const program = Effect.gen(function* () {
  const validation = yield* ValidationService;
  const db = yield* DatabaseService;
  const payu = yield* PayUService;
  const cashfree = yield* CashfreeService;

  const validatedBody = yield* validation.validateCreateLinkRequest(body);

  const service = yield* db.getService(validatedBody.service_key);

  // 1) Idempotency: exact same request should return exact same link
  const existing = yield* db.checkIdempotency(validatedBody.idempotency_key);
  if (existing && existing.payment_url) {
    return { success: true, payment_url: existing.payment_url, cached: true };
  }

  // 2) Reuse: same customer + same service/amount within the same validity window
  const recent = yield* db.findRecentTransaction(
    validatedBody.customer_email,
    service.key,
    service.price_inr,
    Date.now() - 15 * 60 * 1000,
    Date.now()
  );
  if (recent?.payment_url && Date.now() - recent.created_at >= 5 * 60 * 1000) {
    return { success: true, payment_url: recent.payment_url, cached: true };
  }

  const gateway = selectGateway(service);
  const paymentUrl = gateway === "payu"
    ? yield* payu.createPaymentLink({ /* ... */ })
    : yield* cashfree.createPaymentLink({ /* ... */ });

  const transaction = yield* db.createTransaction({
    service_key: service.key,
    amount: service.price_inr,
    customer_email: validatedBody.customer_email,
    gateway,
    payment_url: paymentUrl,
    expires_at: Date.now() + 15 * 60 * 1000,
  });

  return {
    success: true,
    payment_url: paymentUrl,
    link_id: transaction.txn_id,
    gateway,
  };
});

What’s happening:

  • Validate input.
  • Check idempotency key.
  • Fetch service price from D1.
  • Create a link in PayU or Cashfree.
  • Store the transaction.

This is why Effect helps: every failure case is explicit.


Payment UX: keep the user calm

The technical part is only half the job. The other half is: don’t make the user wonder if their payment “worked”.

Here’s what I ended up doing:

  1. A dedicated /pay page that collects name/email/phone (so the gateway can prefill).
  2. A visible 15-minute countdown while the link is being created.
  3. A /pay/status page that shows whatever the gateway returns on redirect (without exposing PII in query params).
Why a dedicated pay page
It keeps the Services page clean (tables, policies, links), and the payment page focused (form + one CTA).

Redirect loop (visual)

sequenceDiagram
    participant Browser
    participant Worker as Workers API
    participant Gateway as PayU/Cashfree
    participant Status as /pay/status
    Browser ->> Worker: POST /api/payment/create-link
    Worker -->> Browser: payment_url (expires in 15 min)
    Browser ->> Gateway: Redirect to payment_url
    Gateway -->> Worker: Return callback (GET or POST)
    Worker -->> Browser: 303 redirect to /pay/status (GET)
    Browser ->> Status: GET /pay/status?gateway=...&status=...

What gets prefilled (and what doesn’t)

Prefilled: customer name, email, phone. That means the gateway page feels “connected” to your site, not like a random checkout link.

Not included in redirects: email/phone/name. Return URLs carry only identifiers like gateway, service, link_id / invoice / order_id.

Details
If you put PII in query strings, it will leak into logs, analytics, browser history, and screenshots. Don’t.

Webhooks: idempotent and state‑safe

Gateways retry webhooks. Some send duplicates. I store every webhook event with a hash and skip duplicates.

Webhook verification + processing (visual)

flowchart TD
    START["Webhook arrives"] --> PARSE["Parse body"]
    PARSE --> SIGN{"Signature valid?"}
    SIGN -->|No| REJECT["Reject (401)"]
    SIGN -->|Yes| DEDUPE{"Duplicate event?"}
    DEDUPE -->|Yes| OK["Return 200 (already processed)"]
    DEDUPE -->|No| STORE["Store webhook event (hash)"]
    STORE --> LOAD["Load transaction / verification"]
    LOAD --> STATE{"Valid state transition?"}
    STATE -->|No| BAD["Reject (400)"]
    STATE -->|Yes| UPDATE["Update status in D1"]
    UPDATE --> SIDEFX["Side effects (email / void)"]
    SIDEFX --> DONE["Return 200 OK"]
    style START fill: #e1f5ff, stroke: #0066cc, color: #000
    style PARSE fill: #fff4e1, stroke: #cc8800, color: #000
    style SIGN fill: #fff4e1, stroke: #cc8800, color: #000
    style REJECT fill: #ffe1e1, stroke: #cc0000, color: #000
    style DEDUPE fill: #fff4e1, stroke: #cc8800, color: #000
    style OK fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style STORE fill: #e1f5ff, stroke: #0066cc, color: #000
    style LOAD fill: #f0e1ff, stroke: #8800cc, color: #000
    style STATE fill: #fff4e1, stroke: #cc8800, color: #000
    style BAD fill: #ffe1e1, stroke: #cc0000, color: #000
    style UPDATE fill: #f0e1ff, stroke: #8800cc, color: #000
    style SIDEFX fill: #f0e1ff, stroke: #8800cc, color: #000
    style DONE fill: #e1ffe1, stroke: #2d7a2d, color: #000

What’s happening here:

  • Signature check is first to block spoofed payloads.
  • Idempotency check stops retries from double-processing.
  • State machine prevents invalid transitions.
1
2
3
4
const isDuplicate = yield* db.checkWebhookDuplicate(eventId);
if (isDuplicate) {
  return { message: "Already processed", status: 200 };
}

For payments, I also enforce a state machine:

1
2
3
4
const isValidTransition = stateMachine.isValidTransition(
  transaction.status,
  newStatus
);

That prevents nonsense like success -> pending if a gateway sends weird retries.

Transaction state machine (visual)

stateDiagram-v2
    [*] --> created
    created --> pending
    created --> expired
    pending --> success
    pending --> failed
    pending --> expired
    success --> refunded

Why this matters:

  • Webhooks are messy; gateways retry with different statuses.
  • The state machine blocks invalid transitions by design.

Card verification: ₹1 pre‑auth + auto‑void

I needed a “verify card” flow that charges ₹1 and reverses it automatically. The Payment Links API doesn’t do that, so I added a separate Orders flow in Cashfree.

The flow

sequenceDiagram
    participant Browser
    participant Worker
    participant Cashfree
    participant D1
    Browser ->> Worker: POST /api/card/verify/session
    Worker ->> Cashfree: Create order (₹1, cc+dc)
    Cashfree -->> Worker: payment_session_id
    Worker ->> D1: insert card_verification
    Worker -->> Browser: payment_session_id
    Cashfree ->> Worker: webhook (AUTHORIZED/SUCCESS)
    Worker ->> D1: update status
    Worker ->> Cashfree: VOID authorization
    Cashfree -->> Worker: void result
    Worker ->> D1: mark voided

The endpoint

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const order = yield* cashfree.createVerificationOrder({
  order_id: `verify_${crypto.generateUUID()}`,
  order_amount: 1.0,
  order_currency: "INR",
  customer_details: {
    customer_id: customerEmail,
    customer_name: customerName,
    customer_email: customerEmail,
    customer_phone: customerPhone,
  },
  order_note: "Card verification (₹1 pre-auth; auto-void)",
  order_meta: { payment_methods: "cc,dc" },
});

What’s happening:

  • The browser gets a payment_session_id, not a link.
  • The webhook updates status and immediately voids successful auths.
  • All records are stored in card_verifications.
Cashfree pre-auth must be enabled
This flow only works if Cashfree has pre‑authorisation enabled on your account. Without it, the VOID call fails.

Storage choices (what goes where)

I kept storage simple and explicit.

flowchart TD
    START["What are you storing?"] --> Q1{"Need SQL + ACID?"}
    Q1 -->|Yes| D1["D1 (transactions, services, webhooks)"]
    Q1 -->|No| Q2{"Binary files?"}
    Q2 -->|Yes| R2["R2 (PDFs, files)"]
    Q2 -->|No| Q3{"Read-heavy cache?"}
    Q3 -->|Yes| KV["KV (sessions, cached config)"]
    Q3 -->|No| WARN["Don’t use KV for counters"]
    style START fill: #e1f5ff, stroke: #0066cc, color: #000
    style Q1 fill: #fff4e1, stroke: #cc8800, color: #000
    style Q2 fill: #fff4e1, stroke: #cc8800, color: #000
    style Q3 fill: #fff4e1, stroke: #cc8800, color: #000
    style D1 fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style R2 fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style KV fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style WARN fill: #ffe1e1, stroke: #cc0000, color: #000

I don’t use KV for rate limiting. It’s eventually consistent, so counters are wrong during spikes. WAF rules are better for edge‑level protection.


The anti‑abuse layer

Public endpoints attract bots. I layered protection:

  1. Cloudflare rate limiting (zone rules) on public POST endpoints.
  2. Idempotency + reuse guards inside the backend.
  3. Turnstile (optional) if you can afford UX friction.

This is the bare minimum. It’s enough for a services page without bringing in Durable Objects.

Why rate limiting first
I tried putting Turnstile everywhere. On paper it’s great. In practice, real browsers and privacy settings can make it flaky. Rate limiting is boring, predictable, and doesn’t ask the customer to “prove they’re human” when they just want to pay.

Anti‑abuse flow (visual)

flowchart TD
    REQ["Public POST request"] --> WAF["Edge rate limit (Cloudflare)"]
    WAF -->|Throttled| THROTTLE["Reject request (429)"]
    WAF -->|OK| MAYBE["Optional Turnstile (feature flag)"]
    MAYBE -->|Disabled| IDEMP["Idempotency + reuse guards (payments only)"]
    MAYBE -->|Enabled + fail| BLOCK["Reject request (403)"]
    MAYBE -->|Enabled + pass| IDEMP["Idempotency + reuse guards (payments only)"]
    IDEMP -->|Duplicate| CACHED["Return cached response"]
    IDEMP -->|Fresh| PROCESS["Process request"]
    style REQ fill: #e1f5ff, stroke: #0066cc, color: #000
    style WAF fill: #fff4e1, stroke: #cc8800, color: #000
    style THROTTLE fill: #ffe1e1, stroke: #cc0000, color: #000
    style MAYBE fill: #fff4e1, stroke: #cc8800, color: #000
    style BLOCK fill: #ffe1e1, stroke: #cc0000, color: #000
    style IDEMP fill: #f0e1ff, stroke: #8800cc, color: #000
    style CACHED fill: #e1ffe1, stroke: #2d7a2d, color: #000
    style PROCESS fill: #e1ffe1, stroke: #2d7a2d, color: #000

What’s happening here:

  • Rate limiting blocks cheap abuse at the edge.
  • Backend guards keep link creation deterministic (idempotency, reuse window, expires_at).
  • Turnstile stays available, but it’s not the only line of defence.

Deployment: the real version

Two pipelines. Same repo. Two deploys.

1) Static site (GitHub Pages)

  • GitHub Actions builds Hugo
  • Output is published to GitHub Pages (vim89/vim89)
  • Cloudflare proxies it under vitthalmirji.com

2) Workers backend

  • Deployed from workers/ via npm run deploy:production
  • Uses workers/wrangler.toml
  • Routes /api/* to the Worker
One repo, two deploys
Static and backend are separate deploys. Same repo, same domain, different pipelines.

Centralize config in one place so you don’t scatter IDs and secrets across docs.

Deployment “grid” (who does what)

PartWhere it runsWhat triggers deployTooling
Static siteGitHub PagesGitHub ActionsHugo + Pagefind
Backend APICloudflare WorkersManual (from workers/)Wrangler

I kept Workers deploy manual on purpose. It avoids accidental API pushes on content‑only changes.

CI/CD excerpt (static site pipeline)

This is the actual GitHub Actions flow that builds and publishes the static site. I’ve kept the InfinityFree steps in the workflow for history, but they’re disabled by a flag and never run by default.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
jobs:
  deploy:
    if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
    env:
      ENABLE_INFINITYFREE_DEPLOY: "false"
    steps:
      - uses: actions/checkout@v5
        with:
          submodules: recursive
      - uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ steps.hugo-version.outputs.HUGO_VERSION }}
          extended: true
      - run: hugo --gc --minify --cleanDestinationDir
      - run: npm test
      - run: npx -y pagefind --site public
      - uses: peaceiris/actions-gh-pages@v3
        with:
          personal_token: ${{ secrets.PERSONAL_TOKEN }}
          publish_dir: ./public
          external_repository: vim89/vim89

What’s happening here:

  • It builds Hugo with a pinned version from .github/hugo.env.
  • It runs tests and Pagefind for search.
  • It publishes to GitHub Pages (external repo).
  • InfinityFree deploy steps are present but gated by ENABLE_INFINITYFREE_DEPLOY=false.
Details
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
jobs:
  deploy:
    if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    needs: resume
    env:
      ENABLE_INFINITYFREE_DEPLOY: "false"
    steps:
      - name: 📥 Checkout code with submodules
        uses: actions/checkout@v5
        with:
          submodules: recursive

      - name: Resolve Hugo version
        id: hugo-version
        run: |
          set -euo pipefail
          . ./.github/hugo.env
          echo "HUGO_VERSION=${HUGO_VERSION}" >> "$GITHUB_OUTPUT"

      - name: ⚙️ Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ steps.hugo-version.outputs.HUGO_VERSION }}
          extended: true

      - name: 🏗️ Build Hugo site
        run: hugo --gc --minify --cleanDestinationDir

      - name: 🧪 Run sanity tests
        run: npm test

      - name: Install Pagefind and build search index
        run: |
          npx -y pagefind --site public

      - name: 📦 Upload to GitHub Pages (gh-pages)
        uses: peaceiris/actions-gh-pages@v3
        with:
          personal_token: ${{ secrets.PERSONAL_TOKEN }}
          publish_dir: ./public
          external_repository: vim89/vim89
          force_orphan: true

      - name: 🚀 Package site for InfinityFree
        if: env.ENABLE_INFINITYFREE_DEPLOY == 'true'
        run: ./scripts/deploy.sh package

What I’m not building (scope matters)

Not building:

  • carts or subscriptions,
  • user accounts,
  • refunds and disputes,
  • anything that needs long‑running jobs.

Building:

  • payment links,
  • webhooks,
  • logs and audit trails,
  • a clean place for the contact form.

Scope discipline is why this stays manageable.


The honest tradeoffs

What you lose

  • Debugging is harder: you tail logs, you don’t step‑debug in prod.
  • Vendor coupling: D1/R2 are Cloudflare‑specific.
  • Limits exist: CPU time and DB size caps mean you can’t build everything here.

What you gain

  • No server ops: deploy and forget.
  • Fast by default: requests run at the edge.
  • Safer code: error handling is forced at compile time.
  • Minimal moving parts: fewer systems to keep alive.

What I’d do differently next time

  • Start with a scope doc instead of tweaking as I go.
  • Separate payment links and card verification earlier (it affects data model).
  • Add the smoke‑test script on day one, not at the end.

TL;DR

  • Hugo is static HTML. Payments and webhooks need a backend.
  • The clean split is: GitHub Pages for static, Workers for /api/*.
  • Effect TypeScript makes errors explicit and forces proper handling.
  • D1 can bootstrap schema once per isolate, but schema changes still need migrations (ALTER TABLE, backfills, indexes).
  • Card verification is a separate Cashfree Orders flow (₹1 pre‑auth + auto‑void).
  • Payment links expire in 15 minutes; store expires_at and reuse links only within a safe window.
  • For abuse: rate limit at the edge first; keep Turnstile optional.
  • Keep the scope tiny. That’s the whole point.

References

Core tech

Hugo

Payments

Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

Staff Data Engineer & Architect from Mumbai, India. Sharing insights on Data Engineering, Functional programming, Scala, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "Data Engineer's Guide to Why Your Pipeline Failed at 3 AM (Again)"