youndie@kotlin.website:~/src$ cat xyk/README.md

xyk

kotlin/native · ktor · sqlite · webhooks
A durable webhook gateway in a very small container: the event row and the timer that will deliver it are committed in one transaction, so an accepted webhook cannot go missing. Kotlin/Native, Ktor and SQLite, measured against criteria declared before the code.

Why it exists

A webhook receiver has one job that is easy to state and easy to get wrong: once it has said 200, the event must not be lost. Most receivers answer first and persist second, or persist the event and schedule its delivery as two separate writes — and the gap between them is exactly where a restart loses a payment notification. xyk accepts a webhook from anyone who can POST — GitHub, Telegram, Stripe — proves it is genuine, writes it down before answering, and delivers it to every subscriber with retries, backoff and per-attempt timeouts. The event row and the timer that will deliver it are committed in one transaction.

It is written in Kotlin/Native on Ktor with SQLite for a second reason: to find out, against numbers declared before the code, whether that platform can hold the promise inside a container small enough to run one per project. The same scenarios are measured against a Go twin of the ingest path, and the two criteria it misses are written down as missed rather than restated to fit.

What you get

  • Signature verification over the exact bytes — X-Hub-Signature-256 the way GitHub sends it; the same request without the header is 401.
  • Delivery to every subscriber with retries, backoff and a timeout per attempt, from four workers by default — a number measured rather than guessed.
  • A journal page at /journal showing what arrived and what happened to it, attempt by attempt, and the same thing as JSON at GET /api/events.
  • Refusal over defaults: a value the service cannot read — XYK_MAX_BODY_BYTES=banana — prints one line naming the variable and exits 78, instead of starting on a number nobody chose.
  • A cold start of 0.394 s to the first 200 on a k0s node, indistinguishable from the Go twin's 0.395.

Install

docker run -d --name xyk -p 8080:8080 -v xyk-data:/data \
  -e XYK_BOOTSTRAP_ENDPOINT_ID=hook-1 \
  -e XYK_BOOTSTRAP_SECRET=a-secret-you-choose \
  ghcr.io/youndie/xyk:main

main moves; sha-<commit> does not — deploy the second. XYK_DB_PATH is the only required variable and the image sets it to /data/xyk.db: without a volume there, every accepted and not yet delivered webhook is lost when the container is replaced. The volume is as sensitive as the secrets in it.

A Helm chart ships under charts/xyk. It takes the bootstrap secret as an existing Secret rather than a value — a secret in values.yaml is a secret in helm history, and neither forgets — uses Recreate rather than a rolling update because one SQLite file has one writer, and keeps the PVC when the release is removed.

The criteria, and the answers

Criterion Answer
2 000 rps on ingest over 200 connections, no slow state No. 437 rps on four visible cores; the Go twin misses it too, at 601
64 MiB limit, ten runs out of ten Yes for thirty seconds at a time, and no longer — see the first limitation
image at most 10 MB No, by 1.74 MiB, on purpose: the excess is the charset converters without which the journal page returns 500
cold start under a second on a k0s node Yes, 0.394 s
if curl makes a static link impossible, that is a result not triggered — all four link modes link; curl costs 8.8 MB

Limitations

  • ktor-client-curl grows about 2 kB per request, so a memory limit is a time budget rather than a margin: 64 MiB is roughly 90 s at 60 rps, 128 MiB roughly four minutes. It reproduces with one client and no service around it, and the CIO engine is flat — but CIO does not speak TLS on Kotlin/Native, and curl is the only engine that does. Closed as dropped, not fixed, so the tick does not read as a repair.
  • Outbound HTTPS is a build variant. The published image has it; a build without it accepts and journals webhooks and says at start-up that it will not deliver them.
  • One process, one SQLite file, one writer. The chart's Recreate strategy is that fact spelled out; there is no clustering story.
  • The Helm chart was validated with --dry-run=server against a live k0s API server and the container was run with exactly the environment it renders — a full helm install was not completed, because the test node's CNI could not give pods an address. Validated, not deployed.
  • No releases: the image is published as main and as sha-<commit>.