A small self-hosted webhook relay appliance for End Close customers who don't want to grant End Close direct access to their payment processors or internal systems.
Payment-processor webhooks (Payabli first) point at the relay inside your infrastructure. The relay:
- Verifies each webhook (static-header + source-IP for Payabli; HMAC for processors that sign).
- Persists it durably (encrypted, SQLite
synchronous=FULL) before acknowledging — no data loss if End Close is unreachable. - Maps each event to an End Close record through an explicit field map — the map is the allowlist: nothing leaves your network unless a field is named in it (optionally hashed with an appliance-local key). A non-configurable hard denylist (PANs, SSNs, CVV/account-number-named fields) applies on top of every mapped value.
- Forwards records to End Close's public API (
POST /v1/records/bulk,X-API-KEY), with exponential-backoff retries and idempotency at both ends. Failed events park visibly; they are never silently dropped.
You hold the killswitches: pause (buffer locally, forward nothing) and panic (refuse
ingest entirely). Neither can be flipped remotely — End Close's visibility into the relay is
read-only and metadata-only.
Everything is managed in a web UI served by the appliance itself at :8081 (React, built
into the image — no CDN assets, so egress allowlists stay tight), protected by mandatory
basic auth (ADMIN_BASIC_AUTH). The shipped compose file publishes it to the Docker
host's loopback only; reach it over an SSH tunnel, or deliberately expose it behind your
own internal TLS proxy.
- status — queues, killswitch state and controls (pause/resume/panic with confirmations), per-route pause toggles.
- events — browse buffered/delivered/parked events (payloads are never shown); replay parked events individually or in bulk.
- config — the declarative YAML config, edited in place: validate against the schema, preview exactly what would leave your network for a sample payload (including every field that is not forwarded), apply, and browse/restore the full version history. Download the active config as YAML at any time.
- audit — the append-only audit log (killswitch flips, config applies, replays), downloadable as JSONL.
Boot checks: if required env (RELAY_DATA_KEY, MASKING_HMAC_KEY, ADMIN_BASIC_AUTH)
is missing or invalid, the relay serves an unauthenticated setup page on :8081
naming exactly what's wrong instead of crash-looping — nothing else runs until it's
fixed. Once running, the UI banners any config-referenced secret that isn't set (a
missing End Close API key buffers events rather than failing boot).
Prometheus metrics + /healthz /readyz probes are at :9090 (optional basic auth via
METRICS_BASIC_AUTH): relay_ingest_total, relay_forward_total, relay_queue_depth,
relay_delivery_lag_seconds, relay_killswitch_state, relay_db_bytes.
Retention: payloads of delivered/filtered events are wiped after 7 days and their rows
(the idempotency ledger) deleted after 30 (retention: in the config). Parked events are
kept until replayed — never silently dropped.
- docs/SECURITY.md — the security-review packet: what leaves your network, what End Close can and cannot do, encryption, retention, audit, supply chain. Start here if you're a security team.
- docs/ONBOARDING.md — install → Payabli setup → masking sign-off → go-live → operations runbooks.
- docs/CONFIG.md — the complete configuration reference.
Customer installs are Distr-managed by default: an open-source agent
(distr.sh) on the customer host polls outbound-only, pulls the
relay image from End Close's registry, and applies versions the customer approves in
their portal. distr/docker-compose.yaml is the compose file delivered through that
channel (external data volume — undeploy never deletes data; secrets via the
customer's Distr Secrets or a host file in strict mode). A fresh appliance boots into
bootstrap mode: the admin UI serves a setup editor for the initial configuration
and the relay restarts itself into running mode after the first apply. Full flow:
docs/ONBOARDING.md.
Run the Prepare release workflow (Actions tab → choose patch/minor/major). It bumps
package.json and the quick-start compose image tag and opens a release/vX.Y.Z PR;
CI runs on it like any change. Merging that PR tags main automatically
(tag-release.yml), and the tag fires release.yml: tests → multi-arch image to
registry.distr.sh/end-close/relay and ghcr.io/end-close/relay → Distr application
version (customers approve the rollout in their portal). Manual tagging still works —
the release gate verifies tag ↔ package.json ↔ compose consistency either way.
Requires the endclose-release GitHub App (org-owned; Contents + Pull requests
read/write; installed on this repo) with RELEASE_APP_ID as a repo variable and
RELEASE_APP_PRIVATE_KEY as a secret — the workflows mint short-lived app tokens per
run, and release PRs/tags are authored by the app bot. The default GITHUB_TOKEN
can't drive this: events it creates don't trigger other workflows, so its PRs would
never run CI and its tags would never run the release.
The root docker-compose.yaml below is the manual/dev variant of the same appliance.
mkdir -p /etc/endclose-relay
cp relay.example.yaml /etc/endclose-relay/relay.yaml # the first-boot seed
# Provide the required env vars however you manage secrets; the compose file also
# reads an optional .env next to it if that's your preference:
# ENDCLOSE_API_KEY, PAYABLI_WEBHOOK_SECRET (Bearer <token you also set in Payabli>),
# RELAY_DATA_KEY + MASKING_HMAC_KEY (32+ random chars each),
# ADMIN_BASIC_AUTH=admin:<strong password>
docker compose up -dOpen http://127.0.0.1:8081 (basic auth) on the host — the config tab shows the seeded
config; from here on the UI is how configuration changes. Then configure the Payabli
notifications (payout_batch_settlement_funded, payout_batch_paid) to POST to
https://<your-host>/ingest/payabli-settlements and .../ingest/payabli-batches with
the matching Authorization header via webHeaderParameters.
- Config lives in the appliance, on your volume. The database is authoritative:
every change made in the UI becomes a new immutable config version (hash + timestamp,
full history retained).
relay.yamlon disk is only read once, to seed an empty appliance — redeploys, image updates, and host re-provisioning can never clobber UI-made changes, because the config travels with therelay-datavolume, which updates don't touch. - Updates are ours. A new version is a new image tag; updating = pull + recreate against the same volume. End Close commits to config-schema compatibility within a major version (additive changes only) — enforced mechanically in CI, where the shipped configs must stay parseable.
- Export for your records. Download the active YAML from the config tab (e.g. after a masking sign-off, or to keep a copy in your git); seeding a replacement appliance with an exported file reproduces the config exactly.
- Undeploy ≠ update.
docker compose down -vdeletes the volume — config and buffered events. It's a killswitch, not an upgrade path. Include the volume in your backups.
pnpm install
pnpm test # unit + integration (mock End Close API)
pnpm typecheck
pnpm dev:all # mprocs: relay (watch mode) + mock End Close APIpnpm dev:all starts mprocs with the relay in watch
mode (seed config dev/relay.dev.yaml, dev secrets from mprocs.yaml, admin auth
dev:dev) and a mock End Close API that prints every record the relay forwards. Select
the webhooks process and press s to fire the Payabli fixture webhooks; test runs
vitest in watch mode; ui runs the admin UI (ui/) on :5173 with Vite HMR, proxying API
calls to the relay — the relay itself serves the built UI from dist/admin-ui after
pnpm build. Config is seeded into ./data/dev.db on first boot; rm -rf data/ to
reseed from the YAML.
The relay makes outbound connections to api.endclose.com:443 only.