Create HTTP endpoints quickly using files, without going through a framework like Flask or FastAPI.
- File-based routing: the directory structure defines the routes, the file name defines the HTTP method.
- Hot reload: save the file and the server updates automatically. Deploy with a simple
git pull. - Per-endpoint dependencies: each endpoint declares its dependencies with PEP 723 and they are installed automatically in an isolated environment.
Bitpoint is for when the API is not the product, just plumbing: a webhook, a mock for the frontend, a small internal service, a few endpoints on a personal server. You write one file and you get one endpoint.
There is nothing to register and nothing to configure: no app object, no decorators, no config files, just a handle function per file. Dependency isolation is delegated to uv, and responses follow the type you return: a string becomes plain text, a dict becomes JSON.
Some use cases:
- Webhooks
- Internal tools
- API mocks
- Glue between services
- Personal server
- Easy deployment
pip install bitpointOr, if you use uv:
uv tool install bitpointPython 3.9 or later. If you want per-endpoint dependencies, uv must be available on the server.
Create a directory called routes and, inside it, a subdirectory called hello (this will be the route). Inside hello, create a file called get.py with the following content:
# routes/hello/get.py
def handle(request):
return "world"Start the server with:
bitpointpython -m bitpoint works too. And with uv you can even skip the installation step and run it directly:
uvx bitpointYou can now try your endpoint:
curl 127.0.0.1:8000/helloIt will return:
world
The URL path maps to the directory path inside routes, and the HTTP method maps to the file name:
routes/
├── webhooks/
│ └── github/
│ └── post.py → POST /webhooks/github (a webhook receiver)
├── invoices/
│ ├── get.py → GET /invoices (an internal service, or a mock)
│ ├── post.py → POST /invoices
│ └── [id]/
│ ├── get.py → GET /invoices/42
│ └── delete.py → DELETE /invoices/42
└── lib/ → shared code, does not generate routes
└── db.py
Valid file names are the HTTP methods in lowercase: get.py, post.py, put.py, patch.py, delete.py, head.py and options.py. Any other file is ignored and does not generate routes, so you can keep helper modules next to your endpoints.
Prefix a name with an underscore to disable it without deleting anything. Renaming get.py to _get.py stops serving that method, and renaming a directory to _invoices/ takes its whole subtree offline: requests respond with 404 (or 405 if the route still serves other methods). Rename it back and the endpoint is live again, no restart needed.
One detail: a [param] directory still captures URL segments that happen to start with an underscore, disabling only affects how your files are routed, not which URLs are valid.
A directory whose name is wrapped in brackets captures a URL segment:
# routes/invoices/[id]/get.py
def handle(request):
return {"invoice_id": request.params["id"]}curl 127.0.0.1:8000/invoices/42
# {"invoice_id": "42"}Captured values always arrive as str. Type conversion is the endpoint's responsibility.
Each endpoint exposes a handle(request) function. It is the only symbol Bitpoint looks for in the file, the rest of the module is yours.
| Attribute | Type | Description |
|---|---|---|
request.method |
str |
HTTP method in uppercase, for example "GET" |
request.path |
str |
Request path, for example /users/42 |
request.params |
dict[str, str] |
Dynamic path segments |
request.args |
dict[str, str] |
Query string parameters |
request.headers |
dict[str, str] |
Headers, case-insensitive keys |
request.body |
bytes |
Raw request body |
request.json() |
Any |
Body parsed as JSON, raises a 400 error if invalid |
Example with a query string:
# routes/greet/get.py
def handle(request):
name = request.args.get("name", "world")
return f"Hello, {name}!"curl "127.0.0.1:8000/greet?name=Bob"
# Hello, Bob!The return type determines the response:
| Return | Response |
|---|---|
str |
200, text/plain; charset=utf-8 |
dict or list |
200, application/json |
bytes |
200, application/octet-stream |
(body, status) |
As above, with the given status code |
(body, status, headers) |
Additionally with custom headers |
None |
204 No Content |
# routes/users/post.py
def handle(request):
data = request.json()
return {"created": data["name"]}, 201- If
handleraises an exception, Bitpoint responds with500. - In development mode (the default) the body includes the traceback. In production (
--production) the body is a generic message and the traceback goes to the log. - Path with no matching directory:
404. Directory exists but there is no file for that method:405with theAllowheader listing the available methods.
Declare each endpoint's dependencies with a PEP 723 block at the top of the file, the same format understood by uv and pipx:
# routes/status/get.py
# /// script
# dependencies = ["requests"]
# ///
def handle(request):
import requests
return requests.get("https://api.github.com/repos/tanrax/bitpoint").json()Bitpoint delegates resolution and installation to uv: each endpoint runs with its dependencies in an isolated, cached environment. Two endpoints can use incompatible versions of the same library without conflict.
- An endpoint without a PEP 723 block runs in the base environment, with no extra cost.
- Installation happens on first startup and whenever the dependency block changes, not on every request.
Security note: installing dependencies automatically after a
git pullmeans running third-party code at deploy time. If you prefer to control that step, start with--no-installand Bitpoint will fail with a clear error on endpoints whose dependencies are not already installed.
The routes/lib/ directory is reserved: it does not generate routes and is importable from any endpoint:
# routes/lib/db.py
def get_connection():
...# routes/users/get.py
from lib.db import get_connection
def handle(request):
conn = get_connection()
...Everything is controlled from the command line, there is no configuration file. The same options work with every launcher: bitpoint, python -m bitpoint or uvx bitpoint.
bitpoint [options]| Option | Default | Description |
|---|---|---|
--port |
8000 |
Listening port |
--host |
127.0.0.1 |
Listening address |
--dir |
./routes |
Root directory for endpoints |
--production |
disabled | Hides tracebacks and disables filesystem-based hot reload |
--no-install |
disabled | Does not install dependencies automatically |
In development mode, Bitpoint watches the routes directory and reloads each module when its file changes. There is no shared state across reloads: if your endpoint keeps in-memory state (caches, connections), it is lost on reload. For persistent state use external resources (a database, Redis, files).
In production, filesystem-based reload is disabled. The recommended deploy flow is git pull followed by a reload signal:
git pull && kill -HUP $(cat bitpoint.pid)A GitHub webhook that sends a Telegram message on every push:
# routes/webhooks/github/post.py
# /// script
# dependencies = ["requests"]
# ///
import os
import requests
def handle(request):
if request.headers.get("x-github-event") != "push":
return None
payload = request.json()
pusher = payload["pusher"]["name"]
repo = payload["repository"]["name"]
commits = len(payload["commits"])
requests.post(
f"https://api.telegram.org/bot{os.environ['TELEGRAM_TOKEN']}/sendMessage",
json={
"chat_id": os.environ["TELEGRAM_CHAT_ID"],
"text": f"{pusher} pushed {commits} commit(s) to {repo}",
},
)
return NoneOne file, deployed with a git pull. No project, no virtualenv, no route registration, no restart.
Some areas are explicitly not covered in order to keep the core small:
- Middleware and global hooks
- Authentication
- WebSockets and streaming
asyncendpoints- Static files
If you need any of this today, Bitpoint is not your tool, you will have to use external tools.