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-256the way GitHub sends it; the same request without the header is401. - 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
/journalshowing what arrived and what happened to it, attempt by attempt, and the same thing as JSON atGET /api/events. - Refusal over defaults: a value the service cannot read —
XYK_MAX_BODY_BYTES=banana— prints one line naming the variable and exits78, instead of starting on a number nobody chose. - A cold start of 0.394 s to the first
200on 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-curlgrows 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
Recreatestrategy is that fact spelled out; there is no clustering story. - The Helm chart was validated with
--dry-run=serveragainst a live k0s API server and the container was run with exactly the environment it renders — a fullhelm installwas not completed, because the test node's CNI could not give pods an address. Validated, not deployed. - No releases: the image is published as
mainand assha-<commit>.