Skip to main content
An optional Go service that accepts provider webhooks, verifies them and queues them, taking that work off PHP. It is optional and the application is complete without it. Without the gateway, webhooks go to the Laravel route exactly as they always have. Deploy it when webhook volume becomes a bottleneck, not because it exists.

What it does

Per delivery: rate-limit by channel, read the routing entry from Redis, verify the signature, claim an idempotency key, publish the job. About a millisecond, no PHP process involved. The second arrow matters more than the first. Redis is a cache for the gateway, never the source of truth. On a cache miss, an unreadable Redis, an unknown platform or a spec it cannot execute, it forwards the request to the application, which resolves the channel from its database and repopulates the cache. A cold or broken Redis costs throughput, not deliveries.

How verification works without platform code

The gateway knows nothing about Telegram. Each channel adapter publishes an ingress spec — a small declaration of how that platform’s webhook is signed and deduplicated:
Adding a channel therefore stays a pure PHP change and needs no gateway release. A platform whose verification is too complex to express declaratively simply does not publish a spec, and its webhooks stay on the PHP path. Both implementations are held to contracts/ingress/golden.json — 40 cases executed by both the PHP and the Go test suites, so the two cannot drift apart.

Installing

With Docker Compose

The gateway ships as a published image, ghcr.io/fapost-lab/gateway, built by the same workflow that publishes core and web. Nothing is compiled locally. It sits behind a Compose profile, so it starts only when asked:
docker/compose.yaml already references the image; GATEWAY_VERSION pins the tag and defaults to latest. The image is built in two stages and the final stage is scratch: it holds the binary, root certificates, and nothing else — no Go toolchain, no shell, no package manager. That keeps it small and leaves an attacker who reaches the most exposed process in the stack with no tools to work with. It runs as UID 65534 and carries no .env, so one image is promoted unchanged from staging to production.

Standalone

Without Docker the gateway needs a binary rather than an image. Neither way of getting one requires a Go toolchain on the server. Out of the published image, if Docker is available on any machine — no build at all:
The image’s final stage is scratch with the binary at /gateway, so this lifts it straight out. The machine you do it on need not be the target server: the binary is static, so only the architecture has to match. From source, if you would rather not pull an image, or you are changing the gateway:
Prerequisites: Go 1.27 or newer. Nothing else; the only dependency is the Redis client, vendored through go.mod. The version is stamped in from git describe, so a running process can be traced back to a commit. Go needs no per-target toolchain, so make dist cross-compiles from whatever machine you have in front of you: build on a Mac, copy the linux/amd64 binary to the server.
If you are modifying the gateway rather than deploying it, its test and lint targets are covered in Testing.

Generating the service files

Either way, once the gateway is available:
It asks how the gateway will run, where providers will deliver, and where to log, then writes the settings into .env and generates a systemd unit, a logrotate config or a compose fragment into gateway/dist/ for you to review and install. Nothing is written outside the project and no service is restarted.

Configuration

Set by gateway:install, or by hand.

Routing

Limits and timeouts

Logging

Redis

Redis settings are not duplicated: the gateway reads the same REDIS_* variables as the application. REDIS_PREFIX in particular must match, or every lookup misses and the gateway proxies everything while appearing healthy.

Migrating existing channels

Switching the driver only affects channels registered from that moment on. Existing ones keep the URL their provider stored, and both paths stay valid, so there is no cutover and no window to race against.
Re-registration reuses the same public hash and secret — only the host changes — so the provider sees no interruption. Batching exists because setWebhook is rate-limited; re-run until the report is empty. Rolling back is the same command after setting the driver back to laravel.

Order of operations

  1. Publish the specs — php artisan ops:ingress-specs-publish
  2. Deploy the gateway and put TLS in front of it
  3. Set WEBHOOK_INGRESS_DRIVER=gateway and WEBHOOK_GATEWAY_URL
  4. Migrate existing channels at your own pace
Step 1 comes first for a reason: without published specs the gateway has nothing to verify against and proxies every delivery straight back to the application. ops:webhook-warmup republishes them too, so a single command restores all ingress state after a Redis flush.

Verifying

Checks the driver, that Redis is reachable, that the key prefix is what the gateway will resolve, that published specs match the adapters, that the gateway’s health endpoint answers, and how many channels are still on the old URL.

Operating

TLS. The gateway speaks plain HTTP; terminate in front with Traefik or Caddy. The terminator must not modify the request body — signatures cover the exact bytes the provider sent. Logs. JSON on stdout by default. In file mode, SIGHUP reopens the file, so logrotate handles rotation; the same signal also drops cached ingress specs, so a republish takes effect without a restart. Shutdown. SIGTERM drains in-flight requests. This matters more than usual: a delivery already answered with 200 will not be sent again, so cutting those requests off loses messages outright. What is never logged. The webhook hash is the credential for a channel, so only a fingerprint of it appears. Request bodies and signature headers never do.