How to Build a Tiny JSON API With Python or Go for Scripts
Build a tiny JSON API in Python or Go for scripts and tests: a few routes, JSON in and out, status codes, a fake backend and safe local binding.

Sometimes a script needs a small service: a fake backend for testing another script, a webhook receiver, a status endpoint, a tiny API that wraps a command. Standing up a framework for four routes is overkill, and the standard libraries are enough. This post builds the same API in Python and Go and tests it from the shell. It extends receiving webhooks with a tiny server and mocking an API locally. The examples assume Python 3.13 and Go 1.23.
The API we want
GET /health -> 200 {"status":"ok"}
GET /items -> 200 [{"id":1,"name":"alpha"}, ...]
POST /items -> 201 {"id":3,"name":"gamma"} (body: {"name":"gamma"})
other -> 404 {"error":"not found"}
Three routes, in-memory data, JSON everywhere. The sample table is the contract the scripts below implement.
Python
#!/usr/bin/env python3
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
items = [{"id": 1, "name": "alpha"}, {"id": 2, "name": "beta"}]
class Handler(BaseHTTPRequestHandler):
def _send(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self._send(200, {"status": "ok"})
elif self.path == "/items":
self._send(200, items)
else:
self._send(404, {"error": "not found"})
def do_POST(self):
if self.path != "/items":
return self._send(404, {"error": "not found"})
length = int(self.headers.get("Content-Length", 0))
if length > 10_000:
return self._send(413, {"error": "body too large"})
try:
data = json.loads(self.rfile.read(length) or b"{}")
name = data["name"]
except (ValueError, KeyError):
return self._send(400, {"error": "need a JSON body with a name"})
item = {"id": len(items) + 1, "name": str(name)}
items.append(item)
self._send(201, item)
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()The standard library server is enough for tests and glue. Each response sets the content type and length, and the POST handler checks the body size and shape before using it, returning 400 or 413 rather than crashing. The server binds to loopback only. It is single-process with shared state; threads make the list mutation non-atomic in theory, which is fine for a toy and a reason not to use this for real work.
Go
package main
import (
"encoding/json"
"net/http"
"sync"
)
type item struct {
ID int `json:"id"`
Name string `json:"name"`
}
var (
mu sync.Mutex
items = []item{{1, "alpha"}, {2, "beta"}}
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
writeJSON(w, 200, items)
})
mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10_000)
var in struct{ Name string `json:"name"` }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" {
writeJSON(w, 400, map[string]string{"error": "need a JSON body with a name"})
return
}
mu.Lock()
defer mu.Unlock()
it := item{len(items) + 1, in.Name}
items = append(items, it)
writeJSON(w, 201, it)
})
http.ListenAndServe("127.0.0.1:8080", mux)
}Since Go 1.22 the standard mux accepts method and path patterns such as GET /items, so no router library is needed, and unmatched routes return 404 automatically (as plain text; wrap the mux if the 404 must be JSON). A mutex protects the shared slice, and MaxBytesReader bounds the body. The result compiles to one static binary, handy to ship as a fake backend in a test suite, see static Go binaries.
Test it from the shell
curl -s localhost:8080/health
curl -s localhost:8080/items | jq 'length'
curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"gamma"}' -w '\n%{http_code}\n' localhost:8080/items
curl -s -X POST -d 'not json' -w '\n%{http_code}\n' localhost:8080/items{"status":"ok"}
2
{"id":3,"name":"gamma"}
201
{"error":"need a JSON body with a name"}
400
The commands exercise the success and failure paths and print the status code with -w. Use jq to check shapes, as in jq for API responses. Wrap these in a Bats test to use the server as a fixture, starting it in the background and killing it in teardown. See testing an API with curl.
Safety and limits
Binding to 127.0.0.1 keeps the service private; binding to 0.0.0.0 exposes it to the network without authentication, so never do that on an untrusted network. There is no TLS, no authentication, no rate limiting and no persistence. Treat the body as untrusted input: limit its size, validate the types and never pass fields to a shell. If it must be reachable by others, put it behind a reverse proxy with TLS, see reverse proxies.
Why the standard library is enough here
A small service has three jobs: parse a request line and headers, route by method and path, and write a response with a correct status and length. Both http.server in Python and net/http in Go do all three, so a framework adds nothing for a handful of routes except a dependency to install and update. For a test fixture that matters. A fake backend that needs pip install before a pipeline can run is one more thing that breaks on a clean machine, while a single Python file or a single Go binary has nothing to resolve.
The limits are real and worth knowing before leaning on the server. Python's ThreadingHTTPServer starts one thread per connection with no cap, so a client that opens thousands of sockets exhausts it. Go's server handles that better, with goroutines that cost a few kilobytes each, but neither has request timeouts configured by default. A client that connects and never sends a header can hold a slot open indefinitely. In Go, set ReadHeaderTimeout and ReadTimeout on the http.Server; in Python, set a socket timeout on the handler class. These two lines separate a toy from something that survives being left running overnight.
Making the fake behave like the real thing
A fake backend earns its value by failing the way the real one does. Add routes that return a 500, a 429 with a Retry-After header and a response that sleeps for several seconds, then point the script under test at them. These are the cases a client usually mishandles, and a real service gives them to you rarely and at inconvenient times. A query parameter such as ?fail=3 that makes the first three calls fail and the fourth succeed lets a test prove that a retry loop gives up or recovers when it should.
Record what the server receives. Appending each request's method, path, headers and body to a list, and exposing it on a debug route, turns the fixture into a spy: the test makes its calls and then asks the server what arrived. This catches bugs that a status code hides, such as a script that sends the right JSON with the wrong content type, or sends a token in the query string instead of the header.
Content types, bodies and the details that cause bugs
Most mistakes in hand-written servers come from the edges of HTTP, not from the routes. Always send Content-Length or use chunked encoding; a response without either forces the client to wait for the connection to close. Set Content-Type: application/json on error bodies too, since a client that parses by header will otherwise choke on a plain-text 404. Read the request body exactly as many bytes as Content-Length states and refuse a missing or oversized value, because trusting it allocates whatever an unfriendly client asks for.
Handle the verbs you do not support on purpose. A request with the wrong method on a known path should get a 405 with an Allow header, not a 404, and a request for a path with a trailing slash should either match or redirect, not fall through silently. The Go 1.22 mux does the first of these for you when you register method patterns, which is one reason to prefer it over a hand-rolled switch.
Running it in a pipeline
Starting the server for a test needs a wait loop, not a fixed sleep. Launch it in the background, then poll the health route with a short timeout until it answers or a deadline passes; a fixed sleep 1 is too long on a fast machine and too short on a loaded runner. Choose the port by asking the OS for one (bind to port 0 and print the number it received) so two jobs on the same host do not collide. Kill the process in a trap so a failed test does not leave a listener behind, and send its log to a file that the pipeline uploads when something fails.
Where this does not apply
For anything beyond tests and glue, use a real framework (FastAPI, Flask, Gin, Echo) and a production server, with routing, validation, logging and graceful shutdown. For faking third-party APIs, dedicated mock servers (Prism, WireMock, json-server) read an OpenAPI description and are faster to set up. See the http.server documentation and the Go ServeMux patterns.


