On this page
This tutorial builds a small Zig server on std.http.Server that looks up each visitor's address with vpndetection, our Zig client library, and asks visitors on a VPN, Tor or a residential proxy to confirm their email at signup. std.http.Server has no proxy setting and no middleware, so the address you look up is the one your code picks, and every answer is memory your code frees. The library supports Zig 0.16.0 and 0.17.0, and Zig's standard library still changes shape between releases, std.http and std.Io among them. We ran every snippet against the live API on October 22, 2026, with Zig 0.16.0 and vpndetection 4.9.0, and every output below is from those runs. The same main.zig, in a project from Zig 0.17.0's zig init, built and answered the same.
Add the library
mkdir screen && cd screen
zig init
zig fetch --save git+https://github.com/vpndetection-io/sdk-zig#v4.9.0zig fetch --save pins the release's commit and hash in build.zig.zon and unpacks the package into zig-pkg/. In build.zig, hand the module to the executable, right after const exe = b.addExecutable(...):
const vpndetection = b.dependency("vpndetection", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("vpndetection", vpndetection.module("vpndetection"));Get an API key in the console: a free one covers 50,000 lookups a month. Without a key, the API allows 1,000 lookups a day per source address, and once deployed, every lookup comes from your server's address.
One client on the program's Io
Replace src/main.zig. Its main takes std.process.Init, which hands it an allocator, an Io and the environment, and the client is built from all three:
const std = @import("std");
const vpndetection = @import("vpndetection");
const net = std.Io.net;
const Request = std.http.Server.Request;
/// Where nginx connects from. Only its X-Forwarded-For is believed.
const proxy: [4]u8 = .{ 172, 28, 5, 12 };
/// What each connection's task is handed.
const App = struct {
gpa: std.mem.Allocator,
io: std.Io,
client: *vpndetection.Client,
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
var client = try vpndetection.Client.init(init.gpa, io, .{
.api_key = init.environ_map.get("VPNDETECTION_API_KEY"),
.timeout = .fromSeconds(2),
.retries = 0,
});
defer client.deinit();
const app: App = .{ .gpa = init.gpa, .io = io, .client = &client };
const address = try net.IpAddress.parse("0.0.0.0", 8092);
var server = try address.listen(io, .{ .reuse_address = true });
defer server.deinit(io);
var connections: std.Io.Group = .init;
defer connections.cancel(io);
while (true) {
const stream = try server.accept(io);
connections.concurrent(io, serve, .{ app, stream }) catch {
stream.close(io);
};
}
}In a Debug build, the default, init.gpa is a DebugAllocator, and init.io is a std.Io.Threaded. The client sends its requests and runs its timeout on that same Io. It's safe to share between tasks, so one client, with one answer cache, serves every connection. App holds a pointer to it, since a client must not be copied once it's built. Set proxy to the address your nginx connects from, 127.0.0.1 when it runs on the same machine.
connections.concurrent gives each connection a task of its own. Group.async looks the same and isn't: it may run the task on the spot, and std.Io.Threaded does once its pool, one thread fewer than the machine has CPUs, is busy. On one CPU that's every connection, so the accept loop serves them one at a time. We pinned the app to one CPU and pointed its api.vpndetection.io at an address that never answers. With async, a health check sent half a second after a signup waited 1.50 s, until the signup's lookup gave up. With concurrent, it answered in about 1 ms.
Answer a request
serve runs one connection, and handle one request on it:
/// One connection, request after request, until the visitor closes it.
fn serve(app: App, stream: net.Stream) void {
defer stream.close(app.io);
var in_buffer: [8192]u8 = undefined;
var out_buffer: [8192]u8 = undefined;
var in = stream.reader(app.io, &in_buffer);
var out = stream.writer(app.io, &out_buffer);
var http: std.http.Server = .init(&in.interface, &out.interface);
// What a request allocates lives until it has been answered.
var arena: std.heap.ArenaAllocator = .init(app.gpa);
defer arena.deinit();
while (true) {
defer _ = arena.reset(.retain_capacity);
var request = http.receiveHead() catch return;
const peer = stream.socket.address;
handle(app, arena.allocator(), peer, &request) catch return;
}
}
/// /check shows the answer, /signup decides, and nothing else is looked up.
fn handle(
app: App,
arena: std.mem.Allocator,
peer: net.IpAddress,
request: *Request,
) !void {
// A request with neither Content-Length nor Transfer-Encoding has no body,
// but respond() asserts one is set, and a failed assert ends the server.
const head = &request.head;
if (head.content_length == null and head.transfer_encoding == .none) {
head.content_length = 0;
}
const check = std.mem.eql(u8, head.target, "/check");
if (!check and !std.mem.eql(u8, head.target, "/signup")) {
return request.respond("ok\n", .{});
}
var buffer: [64]u8 = undefined;
const ip = try visitor(peer, request, &buffer);
const body = answer(app.client, arena, ip, check) catch |err| switch (err) {
error.OutOfMemory => return err,
// Fail open: let the visitor in, but have signup ask for more.
else => if (check) "null" else "{\"confirm_email\":true}",
};
return request.respond(body, .{ .extra_headers = &.{
.{ .name = "content-type", .value = "application/json" },
} });
}The first lines of handle keep the server up. curl -X POST sends a POST with neither Content-Length nor Transfer-Encoding, which in HTTP/1.1 has an empty body, and respond asserts that one of the two is set. Without those lines, one such request panicked inside respond, and the server's process died with it. It's a known issue, fixed on Zig's master branch on October 12, after 0.17.0.
answer looks the visitor up and writes the response:
/// The response for one visitor, built in the request's arena.
fn answer(
client: *vpndetection.Client,
arena: std.mem.Allocator,
ip: []const u8,
check: bool,
) vpndetection.CallError![]const u8 {
var diagnostics: vpndetection.Diagnostics = .{};
const call: vpndetection.CallOptions = .{ .diagnostics = &diagnostics };
const result = client.lookupWith(ip, call) catch |err| {
std.log.warn("lookup failed: {s}: {s}", .{
vpndetection.kindName(err), diagnostics.message(),
});
return err;
};
defer result.deinit();
const json: std.json.Stringify.Options = .{
.emit_null_optional_fields = false,
};
if (check) return std.json.Stringify.valueAlloc(arena, result.value, json);
// A flag your plan leaves out is null, and orelse false reads it as no.
const masked = result.value.is_vpn or (result.value.is_tor orelse false) or
(result.value.is_resproxy orelse false);
return std.json.Stringify.valueAlloc(arena, .{ .confirm_email = masked }, json);
}lookupWith is lookup with this call's own options, here a Diagnostics that holds the detail behind a failure. Start the app with your key:
export VPNDETECTION_API_KEY=...
zig build runFrom 141.98.252.67, a Mullvad server in London from Mullvad's own server list, part of /check read:
{
"ip": "141.98.252.67",
"is_vpn": true,
"is_hosting": true,
"is_tor": false,
"is_resproxy": false,
"vpn": {
"provider": "mullvad",
"last_seen": "2026-10-21",
"confidence": "high"
}
}That's a key on the Max plan. The answer also has location and asn: the United Kingdom and AS39351 here. A home broadband address on a US cable ISP came back with every flag false, every detail object empty, and location and asn filled in. POST /signup answered {"confirm_email":true} to the Mullvad server and {"confirm_email":false} to the home line. A private or reserved address never reaches the API: the client answers it itself.
Which address to look up
std.http.Server has no idea of a proxy, so peer, the address accept returned, is the visitor only when nothing sits in between. visitor reads X-Forwarded-For by hand, and only on a connection from proxy:
/// The visitor's address: the connection's own, unless the connection is
/// nginx's, which appends the address it saw to X-Forwarded-For.
fn visitor(
peer: net.IpAddress,
request: *const Request,
buffer: []u8,
) ![]const u8 {
if (peer == .ip4 and std.mem.eql(u8, &peer.ip4.bytes, &proxy)) {
if (lastForwardedFor(request)) |entry| return entry;
}
return addressOnly(peer, buffer);
}
/// The right-most entry of the last X-Forwarded-For header, if there is one.
fn lastForwardedFor(request: *const Request) ?[]const u8 {
var last: ?[]const u8 = null;
var headers = request.iterateHeaders();
while (headers.next()) |header| {
if (!std.ascii.eqlIgnoreCase(header.name, "x-forwarded-for")) continue;
var entries = std.mem.splitBackwardsScalar(u8, header.value, ',');
last = std.mem.trim(u8, entries.first(), " ");
}
return last;
}
/// The address alone. Formatted with {f}, an IpAddress carries its port.
fn addressOnly(peer: net.IpAddress, buffer: []u8) ![]const u8 {
return switch (peer) {
.ip4 => |a| std.fmt.bufPrint(buffer, "{d}.{d}.{d}.{d}", .{
a.bytes[0], a.bytes[1], a.bytes[2], a.bytes[3],
}),
.ip6 => |a| std.fmt.bufPrint(buffer, "{f}", .{
net.Ip6Address.Unresolved{ .bytes = a.bytes, .interface_name = null },
}),
};
}We put nginx 1.29.1 in front of the app, in a container of its own at 172.28.5.12, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. The Mullvad server sent requests through it and straight to the app, with and without an X-Forwarded-For: 1.1.1.1 of its own. The address each way of picking it looked up:
visitor reads | Through nginx | Through nginx, forged | Straight, forged |
|---|---|---|---|
peer alone | nginx | nginx | the visitor |
| The left-most entry | the visitor | 1.1.1.1 | 1.1.1.1 |
| The right-most entry | the visitor | the visitor | 1.1.1.1 |
| The right-most, from nginx | the visitor | the visitor | the visitor |
nginx appends the address it saw, so the right-most entry is the one nginx wrote, and a forged one stays to its left. Straight to the app, the only entry was the one the visitor wrote, and 1.1.1.1 answers is_hosting and is_cdn but not is_vpn, so signup didn't ask them to confirm. Reading the header only on a connection from proxy closed that.
Two ways to get this wrong are Zig's own. {f} writes an IpAddress with its port after it, so addressOnly writes the bytes itself: given {f}'s text, the API refused every visitor straight to the app as not a valid IP address. And visitor compares bytes because IpAddress.eql compares ports too: a check written with eql against nginx's address never matched, so every visitor through nginx was looked up as 172.28.5.12, a private address the client answers itself, with every flag false.
Who frees a Lookup
lookupWith returns a Lookup allocated with the allocator you gave Client.init, here init.gpa. Everything in it, the strings in value and the API's own JSON in raw, lives in the Lookup's own arena, which deinit frees. The request's arena in serve holds what handle allocates and is reset after each request, but the Lookup isn't in it. So answer frees the Lookup with defer result.deinit() and builds the response in the request's arena before that runs.
Nothing from a Lookup outlives its deinit. A version of answer that returned result.raw for /check, rather than writing a copy, handed respond memory the defer had just freed. On vpndetection 4.9.0 the first /check crashed the server with a segmentation fault. On 4.7.0 it had happened to answer, from memory nothing owned any more. A cached answer is no different: the client keeps the API's body and parses a new Lookup from it on every hit, which needs its own deinit.
A missing deinit crashes nothing: the server answered as before. std.testing.allocator is a DebugAllocator that fails a test which leaves memory behind. A documentation address is answered inside the client, so this test needs no network and no key, and its Lookup is allocated like any other:
test "a lookup is freed" {
const gpa = std.testing.allocator;
var client = try vpndetection.Client.init(gpa, std.testing.io, .{});
defer client.deinit();
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
// A documentation address, answered by the client without a request.
const body = try answer(&client, arena.allocator(), "192.0.2.10", false);
try std.testing.expectEqualStrings("{\"confirm_email\":false}", body);
}With the defer result.deinit() line deleted, zig build test failed, in part:
test
+- run test 1 pass (1 total); 2 leaks
error: 'main.test.a lookup is freed' leaked 2 allocations:
[DebugAllocator] (err): memory address 0x7fcf9f840020 leaked:
...
/screen/src/main.zig:137:37: 0x12a8ca9 in answer (main.zig)
const result = client.lookupWith(ip, call) catch |err| {
^
/screen/src/main.zig:162:28: 0x129cde8 in test.a lookup is freed (main.zig)
const body = try answer(&client, arena.allocator(), "192.0.2.10", false);
^
...
Build Summary: 4/6 steps succeeded (1 failed); 2/2 tests passed
The test's own check passed, and the build failed on the two allocations left behind, each reported with the stack that made it. With the line back, zig build test passed. init.gpa checks for leaks only as main returns, which a server stopped with Ctrl-C never does, and ours logged nothing.
When a lookup fails
The client's defaults are 30 seconds an attempt and two retries, which suits a script better than a signup form. We pointed the app's api.vpndetection.io at an address that never answers: with the defaults, signup answered after 90.75 s, and with the 2-second timeout and no retries in main, after 2.00 s. Pointed at one that refuses connections, it took 0.76 s with the defaults and at most 14 ms with ours.
Each time, answer logged the reason it read from Diagnostics, such as warning: lookup failed: network: the request timed out after 2000 ms, and handle failed open: signup asked for an email confirmation, and /check answered null. With an invalid key, the log read warning: lookup failed: unauthorized: invalid API key, and signup did the same.
The client keeps answers for an hour, up to 10,000 addresses. Counted at a proxy that logged every call to our API, from a fresh start, 50 signups from the Mullvad server cost one lookup, and 20 requests to /healthz cost none. A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million distinct visitors a day, download the VPN IP database and look addresses up locally instead of calling the API.
Absent is not false
Started without a key, the app's /check for the Mullvad server read:
{
"ip": "141.98.252.67",
"is_vpn": true
}Every other field of the Answer is null, which /check leaves out, and null means "not in your plan", not "checked, and no". orelse false in answer reads it as no, so with no key 190.120.229.98, a Tor exit from the Tor Project's list, signed up without confirming an email. With a key, its is_tor came back true and signup asked. Compare with null where the difference matters, as in result.value.is_tor == null. is_vpn comes back without a key, a free key adds the Tor and relay flags, location and asn, and every paid plan answers every field.
The library's README covers batch lookups and database downloads too.


