If you've spent any time building web services in C, you know the pain.
libpapago takes a different approach from other web frameworks in C. It's a modern, full-featured C web framework designed to be genuinely simple to use.. If you can read a route handler in Express or Go, you can read a route handler in papago.
Let's dig in.
Why C for Web Services?
Before getting into the framework, it's worth asking the obvious question. Modern C web services make a lot of sense in the right contexts:
- Embedded systems and IoT where runtime footprint matters
- High-performance infrastructure where you need predictable, low-latency behavior
- Systems-adjacent APIs that live close to hardware or OS interfaces
- Extending existing C codebases without introducing a language boundary
The problem has never been C's ability to serve HTTP. The problem has been ergonomics. That's what papago is designed to solve.
Getting Started
Dependencies
libpapago has a deliberately short dependency list:
-
libmicrohttpd— battle-tested embedded HTTP server -
libwebsockets— WebSocket server and client -
openssl— TLS support -
libmaple— the Maple template engine (optional, for HTML templating)
apt install libmicrohttpd-dev libwebsockets-dev libssl-dev
On FreeBSD via pkg:
pkg install libmicrohttpd libwebsockets openssl
If HTML/text templating is needed:
git clone https://github.com/briandowns/libmaple
cd libmaple
make && sudo make install
Build and Install
make PAPAGO_USE_MAPLE=1
sudo make install PAPAGO_USE_MAPLE=1
Let's take a look at an extremely simple example. When initializing the papago config, it sets reasonable defaults. For example, runs on port 8080, thread pool set to 4, and a body size of 10MB, albeit on the higher end of normal. All of the values are overridable.
#include <stdio.h>
#include "papago.h"
void
hello_handler(papago_request_t *req, papago_response_t *res, void *user_data)
{
PAPAGO_UNUSED(req);
PAPAGO_UNUSED(user_data);
papago_res_json(res, "{\"message\":\"Hello, World!\"}");
}
int
main(void)
{
papago_t *server = papago_new();
papago_config_t config = papago_default_config();
papago_configure(server, &config);
papago_route(server, PAPAGO_GET, "/hello", hello_handler, NULL);
papago_start(server); // blocking
papago_destroy(server);
return 0;
}
Build and run:
cc -o hello hello.c papago.c -lwebsockets -lmicrohttpd -lz -lm -lpthread
./hello
Testing:
curl http://localhost:8080/hello
# {"message":"Hello, World!"}
RESTful Routing
Routing is where papago really shines for its simplicity. Full CRUD looks like this:
papago_route(server, PAPAGO_GET, "/users", list_users, NULL);
papago_route(server, PAPAGO_POST, "/users", create_user, NULL);
papago_route(server, PAPAGO_GET, "/users/:id", get_user, NULL);
papago_route(server, PAPAGO_PUT, "/users/:id", update_user, NULL);
papago_route(server, PAPAGO_DELETE, "/users/:id", delete_user, NULL);
Wildcard Routes
Useful for API versioning prefixes or catch-all handlers:
papago_route(server, PAPAGO_GET, "/api/v1/*", api_v1_handler, NULL);
Path Parameters
Dynamic segments are extracted with papago_req_param():
void
retrieve_id(papago_request_t *req, papago_response_t *res, void *user_data)
{
const char *id = papago_req_param(req, "id");
// fetch user by id, build response...
}
Query Parameters
void
search_handler(papago_request_t *req, papago_response_t *res, void *user_data)
{
const char *q = papago_req_query(req, "q");
const char *page = papago_req_query(req, "page");
// paginated search...
}
Request & Response API
The request/response model is consistent throughout the framework:
void
handler(papago_request_t *req, papago_response_t *res, void *user_data)
{
// read the request
const char *content_type = papago_req_header(req, "Content-Type");
const char *id = papago_req_param(req, "id");
const char *search = papago_req_query(req, "q");
const char *body = papago_req_body(req);
// send the response
papago_res_status(res, PAPAGO_STATUS_OK);
papago_res_header(res, "X-Request-ID", "abc-123");
papago_res_json(res, "{\"status\":\"ok\"}");
}
Middleware
Middleware in papago is composed of two functions, a required before hook and an optional after hook, assigned to a papago_middleware_t struct.
static bool
auth_mw(papago_request_t *req, papago_response_t *res, void *user_data)
{
const char *token = papago_req_header(req, "Authorization");
if (!token || !validate_token(token)) {
papago_res_status(res, PAPAGO_STATUS_UNAUTHORIZED);
papago_res_json(res, "{\"error\":\"unauthorized\"}");
return false; // short-circuit, don't call the route handler
}
return true;
}
papago_middleware_t auth_mw = {
.before = auth_mw,
.after = NULL,
.user_data = NULL,
};
You can attach middleware globally or scope it to a specific path prefix:
// runs on every request
papago_middleware_add(server, auth_mw);
// runs only on /api/* routes
papago_middleware_path_add(server, "/api", auth_mw);
The before return value is the key design point here: returning false short-circuits processing and sends the response immediately, which makes writing authentication and validation middleware clean and explicit.
Websocket Support
papago has first-class WebSocket support with a clean event callback model. You register a set of handlers for the connection lifecycle:
void
ws_on_connect(papago_ws_connection_t *conn)
{
printf("Client connected: %s\n", papago_ws_get_client_ip(conn));
papago_ws_send(conn, "{\"type\":\"welcome\"}");
}
void
ws_on_message(papago_ws_connection_t *conn, const char *message,
size_t length, bool is_binary)
{
// Echo back to sender
papago_ws_send(conn, message);
// Or broadcast to all connected clients
papago_ws_broadcast(papago_get_current_server(), message);
}
void
ws_on_close(papago_ws_connection_t *conn)
{
printf("client disconnected\n");
}
void
ws_on_error(papago_ws_connection_t *conn, const char *error)
{
fprintf(stderr, "error: %s\n", error);
}
// Register the endpoint
papago_ws_endpoint(server, "/ws",
ws_on_connect, ws_on_message, ws_on_close, ws_on_error);
The JavaScript client side is standard:
const ws = new WebSocket('ws://localhost:8081/ws');
ws.onopen = () => ws.send('Hello!');
ws.onmessage = (e) => console.log('Received:', e.data);
WebSocket Client
papago also ships a WebSocket client (papago_wsc), which is included when you build with PAPAGO_WITH_WSC=1. This is particularly useful when your server needs to connect to external WebSocket services like pub/sub brokers, monitoring streams, or service-to-service communication.
File Streaming
For serving large files, video, audio, binary downloads, papago uses zero-copy streaming with automatic MIME type detection. The full set of supported types spans everything you'd expect for a web service:
| Category | Types |
|---|---|
| Text/Web | HTML, CSS, JS, JSON, XML, TXT |
| Images | PNG, JPG, GIF, SVG, ICO, WebP |
| Video | MP4, WebM, OGG |
| Audio | MP3, WAV, M4A |
| Documents/Archives | PDF, ZIP, TAR, GZ |
| Fonts | WOFF, WOFF2, TTF |
No manual Content-Type header setting required.
Examples can be found here.
Rate Limiting
Per-IP rate limiting is built in, no middleware to write yourself and no external library. This is particularly valuable for public-facing APIs where you want basic protection without pulling in a full API gateway.
Setup rate-limiting by registering the rate-limiting middlware like any other middleware and calling papag_enable_rete_limit with max requests and window.
The example below registers the build-in middleware and sets a max request of 5 requests in a 30 second window.
papago_middleware_t rate_limit_mw = {
.before = rate_limit_middleware,
.after = NULL,
.user_data = NULL,
};
papago_enable_rate_limit(server, 5, 30);
papago_middleware_path_add(server, "/", &rate_limit_mw);
Gzip Compression
Response compression is handled transparently. Clients that advertise Accept-Encoding: gzip receive compressed responses, with no code changes required in your handlers.
Enable compression in the config:
papago_config_t config = papago_default_config();
config.enable_compression = true;
Prometheus Metrics
Observability is a first-class feature. A single route registration exposes a Prometheus-compatible /metrics endpoint:
papago_route(server, PAPAGO_GET, "/metrics", papago_metrics_handler, NULL);
Get Involved
libpapago is BSD-2-Clause licensed and actively developed. The examples directory covers the full range of features including basic routing, SSL, WebSockets, middleware, and more.
GitHub: github.com/briandowns/libpapago
Twitter: @bdowns328
If you're building C services and have been waiting for a framework that doesn't make you write everything from scratch, give it a look. PRs are welcome.
Top comments (0)