This document is written for the security team reviewing a deployment of endclose-relay inside your infrastructure. It describes exactly what the appliance does, what data leaves your network, what End Close can and cannot do, and how to verify all of it yourself. Where a claim is enforceable by inspection, we say how to inspect it.
endclose-relay is a self-hosted webhook relay: your payment processor's webhooks are pointed at it, it durably buffers them inside your network, maps an explicitly configured subset of each payload into a financial record, and forwards those records to End Close's public API for reconciliation. It runs as one container plus one Docker volume on a host you control. The source code is available for review.
Payment processor ──HTTPS──▶ relay :8443 (verify → encrypt → persist → ack)
│
▼
SQLite buffer (encrypted payloads, your volume)
│ explicit field map = allowlist
▼
api.endclose.com:443 (HTTPS, X-API-KEY)
Only the fields named in your configuration's map blocks. There is no mode in which unnamed fields are forwarded:
- Each route maps a webhook to a record: date, amount, direction, an external ID, and a
metadataobject containing only explicitly listedoutput_name: source_pathentries. Unmapped payload fields never leave the appliance. - Fields can be forwarded hashed (
transform: hash): a deterministic HMAC-SHA256 keyed byMASKING_HMAC_KEY, which never leaves the appliance. End Close can match equal values across records without ever seeing the raw value. - A non-configurable hard denylist applies on top of every mapped value: Luhn-valid
card numbers (PANs) and SSN patterns inside strings are redacted, and config
validation refuses to map fields whose names indicate card verification codes,
account/routing numbers, SSNs, passwords, or API keys unless they are hashed. No
configuration option disables this; it is compiled into the binary
(
src/mask/defaults.ts).
Verify it yourself: the admin UI's config tab has a map preview: paste a sample payload and see the exact record that would be sent, the source of every field, and every payload field that is not forwarded. It runs locally and sends nothing. Your security team signs off on the map; every applied configuration is stored as an immutable version with its SHA-256 hash, and any later change is visible in the audit log and the version history.
| Receive the mapped records you configured | ✅ |
| See raw webhook payloads, unmapped fields, or hashed originals | ❌ no code path |
| Reach into the appliance (any inbound connection) | ❌ all connections are outbound |
| Flip killswitches, change config, or execute anything remotely | ❌ admin plane is host-local, credentialed by you |
| See your processor credentials or the appliance's keys | ❌ never transmitted or stored server-side |
The relay makes exactly one kind of outbound connection: HTTPS to
api.endclose.com:443, authenticated by an API key you hold. Revoking that key (or
blocking that egress) severs the relationship completely; buffered data stays on your
volume.
- Inbound: one port,
:8443(webhook ingest). Front it with your own TLS termination / load balancer. Requests are verified per route (processor auth header compared in constant time, plus optional source-IP allowlist) before anything is stored. - Admin plane
:8081: the management UI/API, protected by mandatory basic auth (ADMIN_BASIC_AUTH, a credential you generate and hold). The shipped compose file publishes it to the host's loopback only — reachable via SSH tunnel; exposing it wider is a deliberate action on your side and should sit behind your internal TLS (basic auth over plaintext HTTP is only acceptable host-locally). Mutating requests additionally reject cross-site browser calls (Sec-Fetch-Sitechecks; CORS disabled), so a malicious page cannot ride an operator's cached credentials. Failed authentications are delayed to blunt brute force. - Setup mode: if required env (
RELAY_DATA_KEY,MASKING_HMAC_KEY,ADMIN_BASIC_AUTH) is missing or invalid at boot, the relay does not run — it serves a static setup page on:8081naming the offending variable names and answers everything else with 503. That page is intentionally unauthenticated: it exists precisely because the admin credential may be the thing that's missing, it appears only while the relay holds no data and accepts no webhooks, and it discloses nothing but env-var names. - Bootstrap mode: with env present but no configuration yet (fresh appliance, no
seed file), the relay serves only the authenticated admin UI in a setup state where
the initial configuration is entered; ingest is not listening and no webhooks are
accepted, so there is still no data at risk. After the first apply the process restarts
itself into normal operation. In both modes
GET /healthz(unauthenticated liveness, disclosing only{ ok, mode }) reports healthy — deliberately, so fleet-management auto-restart never fights an operator mid-configuration. - Metrics
:9090: not published by default. If you opt in, it exposes operational counters only (no payload data), with optional basic auth (METRICS_BASIC_AUTH). - Egress allowlist for your firewall:
api.endclose.com:443, plus your image registry for pulls. For Distr-managed deployments addapp.distr.sh:443(agent polling — outbound only) andregistry.distr.sh:443(image pulls). Nothing else. No telemetry, no phone-home from the relay itself.
- Buffered payloads are encrypted with AES-256-GCM (per-row random IV) under a key
derived from
RELAY_DATA_KEY, which you generate and hold. A copied volume or backup exposes ciphertext. - Storage is a single SQLite database on a named Docker volume, written with
synchronous=FULLso an acknowledged webhook survives power loss. - Retention: payloads of successfully delivered (or filtered) events are wiped after 7 days; their rows — kept as an idempotency ledger — are deleted after 30. Both are configurable. Failed events are parked, kept until you replay or resolve them, and never silently dropped.
All secrets enter as environment variables on the relay container, provided through whatever secret-management mechanism you already use: the End Close API key, processor webhook secrets, and the two appliance keys. Configuration references secrets by env-var name only — it contains no secret material and is safe to keep in your git. Secrets are never written to the relay's database, logs, or audit log. The logging layer only accepts scalar metadata by construction — there is no API for logging a payload. Missing secrets degrade safely: the UI banners any config-referenced secret that isn't set, and a missing End Close API key means events buffer locally without forwarding — nothing is lost and nothing falls open.
The claim we make: secrets are never accessible to End Close. They are not transmitted to End Close, not stored server-side by End Close, and no End Close-facing surface (records, admin API, dashboards) can return them. Under Distr-managed deployments (below) there are two ways to provide them, with an explicit trade-off:
- Default — Distr Secrets: you enter the five values in your Distr Customer Portal; they are write-only after creation (no API or UI ever returns a value; logs redact them) and referenced from the deployment's env template. Honest caveat, stated plainly: those values are stored in the Distr hub's database, operated by Glasskube on Distr's SaaS — a third party you already trust with more (see the agent note in the fleet-management section).
- Strict mode — host file: if your policy forbids secrets on third-party
infrastructure, put the five variables in
/etc/endclose-relay/relay.envon the host (mode 600) and setRELAY_SECRETS_FILE=/host-config/relay.envin the deployment env. The relay parses the file at boot (values fill unset/blank env vars; names, never values, are logged). In this mode secrets exist only on your host, andMASKING_HMAC_KEY/RELAY_DATA_KEYnever leave it.
Distr-managed deployments (the recommended install) add one component to your host: the
Distr agent, an open-source (Apache-2.0) container by Glasskube that polls
app.distr.sh outbound-only (~5s), deploys/updates the relay's compose stack, and
reports deployment status. Review items, stated plainly:
- The agent mounts the host's Docker socket — root-equivalent on that host — and self-updates from the hub. This is inherent to what it does (it installs and upgrades containers). Practical consequence: Glasskube's supply chain is in your trust boundary for that host, which is also why refusing Distr Secrets while running the agent buys less than it appears to (strict mode exists for policy compliance regardless). The agent's source is public and auditable.
- Updates are pull-based and approved by you in your Distr portal (End Close
publishes versions; nothing auto-rolls). Undeploying via Distr tears down the
containers but preserves the data volume (it is
external); destroying data is a separate explicitdocker volume rmon the host. - The Distr dashboard can show container logs (a per-deployment toggle you control). The relay's logging layer is scalar-metadata-only by construction, so no payload data can appear there; disable the toggle if you want End Close to see only status and versions.
- The relay itself is unchanged by Distr: same image, same ports, same admin plane reachable only from your host.
The appliance database is the configuration's source of truth. Every applied config is
an immutable version row — full YAML, SHA-256 hash, timestamp — and the history is
retained and browsable in the UI. A relay.yaml file is read exactly once, to seed an
empty appliance; after that no file, image update, or redeploy can alter configuration —
only an authenticated admin request can, and each one appends a version and an audit
entry. The active config is exportable as YAML at any time (for sign-off records, your
git, or seeding a replacement appliance). Secrets are not part of configuration: the
YAML references env-var names only, and the UI can neither display nor set secret
values — rotating a credential means changing the container's environment through your
secret-management mechanism and recreating the container.
You hold three levers, flippable only through the authenticated admin plane, never remotely by End Close:
| Lever | Ingest | Forwarding | Data loss |
|---|---|---|---|
| per-route pause | acks + buffers | stopped for that route | none |
| global pause | acks + buffers | stopped | none |
| panic | refused (HTTP 503) | stopped | none within the processor's retry window |
Every killswitch flip, config apply, and event replay is written to an append-only audit log with timestamp and detail — exportable as JSONL from the UI. Attribution is instance-level (the appliance records that an authenticated admin acted and what changed); per-person attribution, if you need it, comes from your own access records for the admin credential and host.
The shipped image and compose file run: non-root user, read-only root filesystem
(tmpfs for /tmp, the data volume as the only writable mount), no-new-privileges,
seed config mounted read-only. Version updates replace the image only; configuration
and buffered data live on the volume, which updates never touch.
- Dependency policy enforced at every install and in CI (
pnpm-workspace.yaml): packages must be at least 24 hours old at resolution time, and only two named packages (better-sqlite3,esbuild) may run install scripts. Exact versions are locked inpnpm-lock.yaml; the package manager itself is version-pinned. - CI runs typecheck, the full test suite, a production build, config-schema compatibility checks against shipped configs, and a Docker image build on every change.
- Signed images with SBOMs are planned as part of the release pipeline; until then, builds are reproducible from source and you can build the image yourself from a reviewed checkout.
- What data leaves our network? Only explicitly mapped fields; preview any payload in the UI's config tab; hard denylist on top.
- Can End Close access our systems? No. No inbound connections, a host-local admin plane credentialed by you, read-only visibility limited to the records you send.
- Where does data live and for how long? Encrypted SQLite on your volume; 7-day payload retention, 30-day ledger, parked events until you resolve them.
- How fast can we stop it? One click in the admin UI (pause or panic); or revoke the API key / block egress. Nothing is lost while paused.
- Who can operate it? Whoever holds the admin credential (and host access for secrets); every action is auditable, attributed at instance level.