Deploying

hookinlet is one program with one data directory. This page says what it needs from wherever you run it. It does not give recipes for particular hosts: any platform that meets these requirements will do.

Quick start

With Docker, two environment variables and a volume:

export HOOKINLET_ADMIN_TOKEN=$(openssl rand -base64 32)   # keep these two
export HOOKINLET_MASTER_KEY=$(openssl rand -base64 32)

docker run -d --name hookinlet -p 127.0.0.1:8080:8080 \
  -v hookinlet-data:/data \
  -e HOOKINLET_ADMIN_TOKEN -e HOOKINLET_MASTER_KEY \
  ghcr.io/hookinlet/hookinlet:latest

(latest is the newest final release. A pre-release such as 0.1.0-rc.1 has only its own version tag.)

Three things people trip on:

If it does not come up, docker logs hookinlet says why.

Create an inbox, send it a webhook, and read it back:

curl -s -X POST localhost:8080/v1/admin/inboxes \
  -H "Authorization: Bearer $HOOKINLET_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"slug": "demo", "verifier": "none"}'
# → {"inbox": {…, "ingest_path": "/in/q3V9xR1hT0kYz2mP8uWb6A"}, "agent_token": "hki_…"}   (the token is shown once)

curl -s -X POST localhost:8080/in/q3V9xR1hT0kYz2mP8uWb6A -d '{"hello": "world"}'
# → {"seq":1}

curl -s localhost:8080/v1/inboxes/demo/messages -H "Authorization: Bearer hki_…"

An inbox whose verifier is none (it has to be asked for) accepts anything sent to its URL and stores it as not verified. To accept only signed webhooks from Stripe, GitHub, and others, see verifiers. All the ways to manage inboxes are in administration, the agent-facing API in api, and the MCP tools in mcp.

Without Docker, download the archive for your platform from the release page, check it, and run it:

sha256sum -c --ignore-missing checksums.txt
tar -xzf hookinlet_<version>_linux_amd64.tar.gz
cd hookinlet_<version>_linux_amd64
HOOKINLET_ADMIN_TOKEN=… HOOKINLET_MASTER_KEY=… ./hookinlet serve

Binaries are built for Linux and macOS on amd64 and arm64. They are static (CGO_ENABLED=0) and need nothing installed. The macOS binaries are not notarized: one downloaded through a browser is quarantined, and xattr -d com.apple.quarantine hookinlet (or fetching it with curl) lets it run.

What it needs

Behind a proxy

hookinlet needs the proxy to terminate TLS, to pass Host and X-Forwarded-For through, not to buffer streams, and to wait long enough on quiet connections. Both snippets below assume hookinlet listens on 127.0.0.1:8080, so set HOOKINLET_TRUSTED_PROXIES=127.0.0.1/32 and HOOKINLET_PUBLIC_BASE_URL=https://hooks.example.com. With Docker, hookinlet sees the proxy at the bridge gateway (often 172.17.0.1), not at 127.0.0.1, so set HOOKINLET_TRUSTED_PROXIES to that address (docker network inspect bridge shows it), and note that HOOKINLET_ADMIN_LISTEN_ADDR=127.0.0.1:... is the container's own loopback, which the host cannot reach: bind it to 0.0.0.0 inside and publish the port on the host's loopback (-p 127.0.0.1:9090:9090). These are starting points written from each proxy's documentation: the project's tests do not run them, so try yours with a test delivery and a stream.

Caddy (TLS is automatic):

hooks.example.com {
	# The admin API, its console and metrics are for you, not the internet. Reach them on the
	# admin address instead (HOOKINLET_ADMIN_LISTEN_ADDR=127.0.0.1:9090) and block them here.
	@private path /v1/admin/* /admin /admin/* /metrics
	respond @private 404

	request_body {
		max_size 11MB     # the largest body an inbox may take is 10 MiB
	}
	reverse_proxy 127.0.0.1:8080 {
		flush_interval -1          # do not hold stream events back
	}
}

nginx (with certificates from your usual source):

server {
    listen 443 ssl;
    server_name hooks.example.com;
    # ssl_certificate / ssl_certificate_key ...

    client_max_body_size 11m;

    location ~ ^/(v1/admin/|admin(/|$)|metrics) { return 404; }   # API, console, metrics: use the admin address instead

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Connection "";
        proxy_buffering off;            # streams and waits
        proxy_read_timeout 90s;         # longer than a 60 s wait and a 15 s heartbeat
    }
}

With either, ask the server whether it is ready from outside (curl -s https://hooks.example.com/readyz), and send a test delivery.

Settings

Every setting is a flag and an environment variable (--listen-addr is also HOOKINLET_LISTEN_ADDR). A flag wins over the variable. hookinlet serve -h lists them all.

Variable Default Meaning
HOOKINLET_ADMIN_TOKEN required Admin credential.
HOOKINLET_MASTER_KEY required Encrypts verifier secrets.
HOOKINLET_LISTEN_ADDR :8080 Address to listen on.
HOOKINLET_DATA_DIR ./data Where the database lives.
HOOKINLET_PUBLIC_BASE_URL from the request Public address, for ingest URLs.
HOOKINLET_TRUSTED_PROXIES none CIDRs whose X-Forwarded-For is believed.
HOOKINLET_LOG_LEVEL info debug, info, warn, error.
HOOKINLET_LOG_FORMAT json json or text.
HOOKINLET_MAX_STREAMS 1000 SSE streams open at once; more get 503. Also bounds MCP waits.
HOOKINLET_MAX_STREAMS_PER_INBOX half of the above Most streams or waits on one inbox.
HOOKINLET_MAX_STREAMS_PER_CREDENTIAL a tenth of HOOKINLET_MAX_STREAMS, at least 20 Most streams or waits one token may hold, so that one token cannot take every slot. The admin token is not limited. Raise it for an agent platform that follows many inboxes with one token.
HOOKINLET_MAX_READS_PER_CREDENTIAL a quarter of the 16 read slots Most read slots one token may hold at once. The admin token is not limited.
HOOKINLET_SSE_HEARTBEAT 15s Ping interval on idle streams.
HOOKINLET_SSE_RECHECK_INTERVAL 0 (off) Re-read the store this often even without a wake-up. Needed only by a notifier that can drop signals; the built-in one does not.
HOOKINLET_ADMIN_ENABLED true Serve the admin API (/v1/admin/...). false leaves inboxes and tokens to the command line.
HOOKINLET_ADMIN_LISTEN_ADDR none Serve the admin API on this address instead of HOOKINLET_LISTEN_ADDR. Only the admin API and the console (/admin) are served there, with the read API the console shows messages with (/v1/inboxes/...; the same tokens work). With the console off (HOOKINLET_ADMIN_UI=false) only the admin API is.
HOOKINLET_MCP_ENABLED true Serve /mcp.
HOOKINLET_ADMIN_UI true Serve the admin console at /admin, on the same listener as the admin API. It needs the admin API on (HOOKINLET_ADMIN_ENABLED).
HOOKINLET_MCP_INLINE_BODY_BYTES 16384 Largest body MCP tools return inline (at most 65536).
HOOKINLET_INGEST_MEMORY_BUDGET 268435456 Most bytes of request bodies held in memory at once by deliveries being received; more get 503. 0 turns the bound off.
HOOKINLET_DRAIN_DELAY 0 After being told to stop, keep serving for this long with /readyz at 503, so a load balancer takes the instance out of rotation first.
HOOKINLET_RETENTION_INTERVAL 1h How often retention runs; off for never.
HOOKINLET_SHUTDOWN_TIMEOUT 10s How long shutdown waits.

A bad setting is reported, all of them at once, and the server does not start.

Commands with the image

The image holds one binary, /hookinlet, so the commands in these documents run through Docker. Against a running container:

docker exec hookinlet /hookinlet inbox list
docker exec hookinlet /hookinlet backup --out /data/backup.db
docker cp hookinlet:/data/backup.db ./backup.db     # or: curl .../v1/admin/backup -o backup.db

The copy stays in the volume, where it is as sensitive as the database and where a second backup --out /data/backup.db is refused without --force. The image has no shell, so remove it with another image: docker run --rm -v hookinlet-data:/data alpine:3 rm /data/backup.db.

To restore a backup into the volume, stop the container, replace the database (and remove the old -wal and -shm files, see Restoring) with another image that has a shell, check it, and start again with the same master key:

docker stop hookinlet
docker run --rm -v hookinlet-data:/data -v "$PWD":/backup:ro alpine:3 sh -c '
  rm -f /data/hookinlet.db /data/hookinlet.db-wal /data/hookinlet.db-shm /data/backup.db &&
  cp /backup/backup.db /data/hookinlet.db &&
  chown 65532:65532 /data/hookinlet.db && chmod 600 /data/hookinlet.db'
docker run --rm -v hookinlet-data:/data ghcr.io/hookinlet/hookinlet:latest check
docker start hookinlet

Commands that need the server stopped (migrate, rekey, renew-epochs) run in a throwaway container on the same volume, with the secrets they need in its environment:

docker stop hookinlet
docker run --rm -v hookinlet-data:/data -e HOOKINLET_MASTER_KEY -e HOOKINLET_OLD_MASTER_KEY \
  ghcr.io/hookinlet/hookinlet:latest rekey

Stopping

Send SIGTERM or SIGINT. With HOOKINLET_DRAIN_DELAY set, it first reports itself not ready for that long while still serving. It stops accepting connections, ends streams and waits, and lets deliveries that are being stored finish, for up to HOOKINLET_SHUTDOWN_TIMEOUT (10 seconds by default); anything still running then is cut off. It then stops the retention sweep and checkpoints the database, which is quick. With HOOKINLET_DRAIN_DELAY set, the platform must wait for the delay and the timeout (Docker's default grace period of 10 seconds equals the default timeout alone, so give docker stop --time or the orchestrator's termination grace period their sum). A second signal ends the process at once. A platform that waits less than the timeout before killing the process risks cutting off a delivery; killing it at once never damages the data (SQLite recovers from the log), but the delivery in flight is lost, and the sender, who got no answer, will normally retry.

Health checks

Neither needs a credential. The image has no curl, so it carries hookinlet healthcheck (it asks /readyz of this machine, at HOOKINLET_LISTEN_ADDR; --live asks /healthz; --url asks another address), and the Dockerfile uses it as its HEALTHCHECK with a start period of 60 seconds for a long migration. It finds the server from the environment, so set HOOKINLET_LISTEN_ADDR rather than passing --listen-addr in the command if you change the port.

The healthcheck is in the image's config. A runtime that ignores it will show no health status: Podman's Docker-compatible API (what the docker command talks to on some systems) does, for example, while podman run honours it. There, give the check yourself with --health-cmd "/hookinlet healthcheck" (or a healthcheck: entry in Compose).

Metrics

GET /metrics serves counters and gauges in the Prometheus text format, with the admin token as a bearer token (so, where the admin API is on its own address, there): deliveries by how they ended, requests by route and status, the ingest memory budget in use, open streams, retention sweeps, and a few process figures. No series is labelled with anything a client chooses (no inbox, path or query), so a scanner cannot add series. A scrape job:

scrape_configs:
  - job_name: hookinlet
    authorization: { credentials_file: /run/secrets/hookinlet_admin_token }
    static_configs: [{ targets: ["hookinlet:8080"] }]

Disk space

A delivery that cannot be stored because the volume is full is answered 503 (storage_full, with Retry-After) and is not acknowledged, so a sender that retries loses nothing, and /readyz fails until there is room again, so a load balancer stops sending. Free space is checked against a floor of 32 MiB. The write-ahead log is cut back to 64 MiB whenever SQLite resets it, so one burst of writes does not leave a large file behind.

One server per data directory

A server takes a lock on the data directory (hookinlet.lock, an advisory lock the operating system drops when the process ends, so there is nothing to clean up after a crash). A second server started on the same directory stops at once and says which process holds it, instead of two writers corrupting each other's work. hookinlet migrate, rekey and renew-epochs, which must not run beside a server, take the same lock. The other commands do not, so hookinlet inbox ... still works in the directory of a running server. On a file system that does not support locks (some network and FUSE mounts) the server starts anyway and logs a warning: nothing then stops a second one.

Starting, and how fast

The server is ready as soon as the database is open: there is no warm-up, no index build, and no network call. Measured from exec to the first successful /readyz on a database of a million messages, restarting with the file already in the operating system's cache, the median of seven starts was 4 ms on Linux and 11 ms on macOS (a 2026 laptop); the limit this project holds itself to is 500 ms. Start-up time does not grow with the size of the database. A start on a host whose disk cache is empty, or the platform's own time to start a container, comes on top of that. Retention, which can be slow on a large database, runs after the server is listening and never delays it. A platform that stops an idle instance and starts it on the next request therefore does not make that request wait for hookinlet.

Scale to zero. An open SSE stream or MCP wait is an open connection, which some platforms count as activity and so keep the instance awake. An agent that wants the instance to sleep when idle should poll /v1/inboxes/{slug}/messages rather than hold a connection.

Resources

One instance is small. Idle, the process uses about 10 to 12 MiB of memory on Linux. Each open SSE stream costs about 50 KiB, so 500 of them come to about 35 MiB. A page of messages is built in memory and bounded (16 MiB at most), and reads are limited to 16 at a time, a slot being held until the response has been written to the client or ResponseTimeout (2 minutes) has passed, so a client that asks and never reads cannot pin more than 16 pages. One credential may hold only a quarter of those slots (4 of 16), so that a client that never reads cannot hold them all and turn every other inbox's readers away; the admin token is not limited. In the same way one credential may hold only a tenth of the stream slots (100 of 1000) and one inbox's deliveries only an eighth of the budget below (32 MiB of 256; never less than its largest body), so that a token or an ingest key cannot take what everyone shares. The first two are settings (HOOKINLET_MAX_READS_PER_CREDENTIAL, HOOKINLET_MAX_STREAMS_PER_CREDENTIAL). Deliveries are not limited in number: each one is held in memory while it is read and checked, up to the inbox's body limit (1 MiB by default, at most 10 MiB), so a burst of large deliveries uses that much each. The total is bounded: deliveries being received hold at most HOOKINLET_INGEST_MEMORY_BUDGET bytes of bodies between them (256 MiB by default; at least 10 MiB, the largest body an inbox may take). The bytes are counted as they arrive, so a connection that has promised a large body and sent none costs nothing, and a delivery whose bytes would take the total over is answered 503 with Retry-After and not stored. The memory the process uses is a small multiple of the bytes counted (buffers grow, and the body is copied into the database). Put a rate limit in front, or in a hook (see extending). Disk is what grows: size the volume for the messages you keep, and set retention on inboxes that receive a lot. A backup needs free space in the data directory for a second copy of the database while it runs. Each stream or wait holds a file descriptor; the default of 1000 needs a limit comfortably above that. A connection cannot be held open by not sending or not reading: whatever a request is sent to, its headers must arrive within 10 seconds and its body within 60, and an answer the client does not take is given up after ResponseTimeout. (An answer that has to wait, for a free read slot or in an MCP wait, is counted from when it is ready, and a request that is refused or answered early is given ResponseTimeout plus the longest wait it could have made. The admin API, which its caller holds the admin token for, has no limit on how long an answer may take to be ready, and a stream or a backup bounds every write on its own.)

Upgrading

Start the new version on the same data directory. It applies any database migrations itself, once, before it listens, and logs each one. A large database can take minutes to migrate (the move of the message body, 0007, took about two and a half on a million rows), during which nothing listens, so a health check that restarts the process will restart the migration too: run hookinlet migrate as its own step with the old server stopped (the migration refuses while another process has the database open) (it applies the migrations and reports the schema version) or give the health check a generous start period. Only the process that holds the data directory's lock migrates: a command such as hookinlet inbox show run against an old database refuses and tells you to stop the server and run hookinlet migrate, so run those after upgrading, not before. Migrations only move forward: a database whose schema is newer than a version knows (one that added a migration) is refused by that older version.

A copy is kept for you. When there are migrations to apply to a database that already holds data, the server first writes a complete copy of it as it was to hookinlet.db.before-migration-N.bak in the data directory (N is the schema version it was at; mode 0600). If the upgrade goes badly, stop the server, put that file back as hookinlet.db (remove any -wal and -shm beside it, and hookinlet.db.migrating), and start the previous release. If the copy cannot be made (no room for a second copy of the database), the server refuses to migrate and changes nothing. If an upgrade is cut short (killed, out of disk, a start timeout), the next start keeps the copy that was made before it began: the database is then partly upgraded, and the copy of that would be no way back. (A marker file, hookinlet.db.migrating, records that; it goes when an upgrade finishes.) Only the latest finished upgrade's copy is kept, and nothing deletes it, so remove it when you trust the upgrade. Room: the migration that moves the message body (0007) rebuilds the messages table, so allow for about three times the database's size free in the data directory (the copy, the new table, and the write-ahead log), and note that the file does not shrink afterwards. It is as sensitive as the database. Taking your own backup first is still sensible. Changes that affect anyone embedding the packages are listed in CHANGELOG.md; this is a pre-1.0 project and those may be breaking.

Backups

GET /v1/admin/backup, hookinlet backup --out FILE, or copying the data directory while the process is stopped. See administration, including how to restore (after restoring from a copy of the data directory, run hookinlet renew-epochs, so that readers' old cursors are not trusted). Keep the master key with the backup, but apart from it.

Logs

Logs go to standard error, as JSON lines unless HOOKINLET_LOG_FORMAT=text. Each request is logged with its route, status, duration, and an X-Request-Id (which is also sent back to the client). They name inboxes by slug and sequence number and never contain message bodies, tokens, or secrets.

Checking a release

Each release has, per archive, a SHA-256 in checksums.txt and a software bill of materials (*.sbom.json). The container image is published for linux/amd64 and linux/arm64 with build provenance and an SBOM attached as attestations (docker buildx imagetools inspect ghcr.io/hookinlet/hookinlet:<version>). A tag with a suffix, such as v0.1.0-rc.1, is a pre-release: its image gets no latest tag.

From docs/deploying.md at v0.1.1. Markdown · View on GitHub