DEV Community

minia2a
minia2a

Posted on • Originally published at minia2a.uk

Rewriting an AI Agent Marketplace from Node.js to Go — Architecture, x402 Payments, and 300+ Services

Why rewrite?

minia2a started as a Node.js Express app — one file, one process, one server. It worked fine for the first month. Then we hit 100 services. Then 200. Then agents started actually paying.

The cracks showed fast:

  • Memory: Node.js process regularly hit 500MB+ under load — mostly from in-memory caches and the runtime itself. On a $20/mo VPS with 1GB RAM, that's half the box.
  • Concurrency: Every x402 proxy call was a blocking HTTP request through axios. When 10 agents called simultaneously, the event loop choked.
  • Startup time: 8+ seconds cold start as it loaded service manifests into memory.
  • Single point of failure: Everything was in one process. Static files, API, payment routing, trial counting — kill one, kill all.

Most importantly: we didn't understand our own codebase anymore. The proxy handler alone was 400 lines of nested async/await with payment verification, trial counting, error handling, and service forwarding all tangled together.

So we rewrote it in Go.

The V5 architecture

The new gateway is ~1,200 lines of Go (vs ~3,000 lines of Node.js) and splits cleanly into four packages:

gateway/
├── cmd/main.go           # Entry point — 30 lines
├── internal/
│   ├── config/           # Env-based config with validation
│   ├── httpapi/          # Gin router + all HTTP handlers
│   ├── gateway/          # x402 payment gate + trial management
│   ├── store/            # SQLite persistence layer
│   └── runtime/          # Service registry + Node.js bridge
Enter fullscreen mode Exit fullscreen mode

The stack

Layer V4 (Node.js) V5 (Go)
HTTP framework Express Gin
Database JSON files + in-memory SQLite (modernc.org/sqlite)
Payment verification Inline axios calls Separated gateway.PaymentVerifier
Trial tracking In-memory Map (lost on restart) In-memory + SQLite persistence
Service execution Direct axios proxy runtime.NodeBridge → Node.js sidecar
Static files express.static gin.RouterGroup + NoRoute fallback

Key packages

internal/httpapi — the router. One Setup() function that wires everything: health endpoint, /api/stats, /api/services, the /x402/:id proxy path, MCP support at /mcp, registration, receipts, and static file fallback. ~300 lines, all routes visible in one file.

internal/gateway — the payment gate. Handles:

  • Trial counting (5 free per IP, persisted to SQLite between restarts)
  • 402 challenge construction (price, wallet address, chain ID)
  • Receipt generation with HMAC verification
  • Daily cache cleanup goroutine

internal/store — SQLite with 8 tables: services, agents, x402_payments, usage_data, trials, receipts, replay_lock, chat_sessions. Zero external dependencies — the SQLite driver is pure Go via modernc.org/sqlite.

internal/runtime — bridges to the Node.js sidecar that actually runs service code. When an agent calls /x402/captcha-solve, the Go gateway verifies payment, then forwards input to the Node process via HTTP, gets the result, and returns it.

The x402 payment flow, in code

Here's what happens when an agent calls a paid service:

Step 1: Agent sends a request

curl "https://minia2a.uk/x402/captcha-solve?sitekey=XXX&url=YYY"
Enter fullscreen mode Exit fullscreen mode

Step 2: Gateway checks for payment or trial

// From internal/gateway/gate.go
func (g *Gate) CheckTrial(ip, walletAddr string) TrialResult {
    // Fast path: in-memory cache
    used := g.ipTrials[key]

    // Slow path: load from DB (survives restart)
    if used == 0 && g.db != nil {
        used = g.loadTrialCount(key)
    }

    if used < AnonTrialMax { // 5 free trials
        g.ipTrials[key] = used + 1
        go g.saveTrialCount(key, used+1) // async persist
        return TrialResult{Allowed: true, Remaining: AnonTrialMax - used - 1}
    }

    return TrialResult{Allowed: false} // → return HTTP 402
}
Enter fullscreen mode Exit fullscreen mode

Step 3: If no trial remaining — HTTP 402 Payment Required

{
  "error": "Payment Required",
  "type": "x402",
  "network": "base",
  "token": "USDC",
  "priceCents": 5,
  "recipient": "0xf16F0882de08315B438E9f3a2Abfb2d2E5d94ECA"
}
Enter fullscreen mode Exit fullscreen mode

This is the x402 protocol — the server returns a payment invoice in the 402 response body. The agent's wallet signs the transaction and retries with a Payment-Signature header.

Step 4: Gateway verifies payment and forwards

Two payment paths:

  • Path A (Facilitator): The agent uses Coinbase CDP, PayAI, or another facilitator. Payment verification is a single API call. Fast (~2s), but requires trusting the facilitator.
  • Path B (On-chain): The agent sends USDC directly on Base. The gateway verifies the on-chain transaction. Slower (~15s for confirmation), but fully trustless.
// From internal/httpapi/router.go — x402Handler
if c.GetHeader("PAYMENT-SIGNATURE") != "" || c.GetHeader("X-Payment-Tx") != "" {
    result, err := payment.VerifyAndSettle(id, priceCents, c.Request)
    if err != nil {
        c.JSON(402, gin.H{"error": "payment verification failed"})
        return
    }
    // Forward to service
    resultPayload, _ := bridge.Call(id, input)
    // Record revenue
    payment.CommitPayment(id, result.Payer, priceCents, ...)
    // Issue receipt
    receipt, _ := gateway.CreateReceipt(db, ...)
    c.Header("X-Minia2a-Receipt", receipt.ID)
    c.Data(200, "application/json", resultPayload)
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Cryptographic receipt

Every call — trial or paid — gets an HMAC-signed receipt:

{
  "id": "rcpt_abc123",
  "type": "payment",
  "service_id": "x402-captcha-solve",
  "amount_cents": 5,
  "currency": "USDC",
  "tx_hash": "0x...",
  "hmac": "sha256:...",
  "verified": true
}
Enter fullscreen mode Exit fullscreen mode

Receipts are publicly verifiable — anyone can check the HMAC to confirm a call really happened. This is the accountability layer that makes agent-to-agent commerce auditable.

Why Go?

1. Compile-time safety

The biggest win wasn't performance — it was catching bugs at compile time. In Node.js, a typo in a JSON field name (trailCount vs trialCount) silently returned undefined. In Go, db.QueryRow("SELECT ...").Scan(&trialCount) fails at the type level if the types don't match.

2. Memory

V5 idles at 12MB. Under load: ~40MB. The Node.js process idled at 80MB and spiked to 500MB+. On a 1GB VPS, that's the difference between "plenty of headroom" and "OOM killer."

3. Deployment

V4: npm installnode server.js → hope node_modules doesn't break.

V5: go build -o gateway ./cmd → one binary, zero dependencies. SCP it to the server, restart PM2, done. The SQLite driver is compiled in — no CGO, no shared libraries, no system package dependencies.

$ ls -lh gateway
-rwxr-xr-x 1 ec2-user ec2-user 14M Aug 7 08:42 gateway
Enter fullscreen mode Exit fullscreen mode

14MB. The entire server, including HTTP framework, SQLite driver, payment verification, and MCP support.

4. Concurrency model

Go's goroutines map naturally to our workload: each x402 proxy call is independent I/O-bound work. No async/await, no Promise chains, no .then() nesting. Just:

go func() {
    result, err := bridge.Call(id, input)
    // handle result
}()
Enter fullscreen mode Exit fullscreen mode

The garbage collector handles cleanup. The runtime schedules goroutines across CPU cores. It's boring technology — and that's the point.

What we kept from Node.js

We didn't throw everything away. The service runtime — the code that actually executes agent API calls (CAPTCHA solving, web scraping, gas price lookups, etc.) — still runs in Node.js. There are 300+ service implementations written in JavaScript, and rewriting all of them made no sense.

Instead, the Go gateway communicates with a lightweight Node.js sidecar process:

Agent → Go Gateway (HTTP) → Node.js Sidecar (HTTP) → Service Execution
       ↑ payment verified    ↑ input forwarded          ↑ result returned
       ↑ trial counted       ↑ output returned
       ↑ receipt issued
Enter fullscreen mode Exit fullscreen mode

The Node.js sidecar is a thin HTTP server that loads service manifests and executes them. It doesn't handle payments, routing, trials, or receipts — that's all Go now. The sidecar only does what JavaScript is good at: dynamically loading and executing service code.

This separation means we can eventually rewrite individual services in Go or any other language without changing the gateway.

Real numbers

As of August 7, 2026:

  • 323 services listed on the marketplace
  • 385,761 total requests served
  • 9,973 free trials used across 322 unique users
  • 53 wallet users with on-chain payment capability
  • 14 paid transactions settled on-chain
  • Gateway memory: 12MB idle, 40MB under load
  • Binary size: 14MB (includes Gin + SQLite + all packages)
  • Build time: <3 seconds
  • Lines of Go: ~1,200 (vs ~3,000 Node.js)

The paid transaction count is low because we give 5 free trials per service, and 247 out of 323 services have trials enabled. Most agents are still in exploration mode — the conversion from trial to paid is the next frontier.

What we learned

1. Rewrite the hot path, not everything. We didn't rewrite 300+ service implementations. We rewrote the gateway — the part that handles routing, payments, trials, and receipts. The services themselves can stay in Node.js indefinitely.

2. SQLite is production-ready for this scale. 385K requests, 300+ services, payment records, receipts — all in a single SQLite file. No Postgres, no Redis, no Docker. Backups are cp data/minia2a.db data/backup.db. The modernc.org/sqlite pure-Go driver means zero CGO dependencies.

3. Separate concerns early. In V4, the proxy handler mixed payment verification, trial counting, service forwarding, and error handling into one 400-line function. In V5, each concern is its own package. When we added MCP support (the /mcp endpoint), it was 40 lines in router.go — no changes to payment logic, store, or runtime.

4. HTTP 402 is a real protocol now. The x402 flow — request → 402 challenge → signed payment → 200 response — is simple enough to implement in any language. Our Go implementation of the payment gate is ~200 lines including trial management and receipt generation.

5. Compile-time safety matters more than you think. Every Node.js runtime error we fixed during the rewrite had been silently passing undefined through the system for weeks. Go's type system caught all of them before the binary was even built.

The code

The gateway is open source. You can see the full architecture at:


This is the first in a series about building agent payment infrastructure. Next: how we handle payment verification trustlessly on-chain, and why we chose USDC on Base over other chains.

Top comments (0)