ZTG Integration Guide
This guide walks you from zero to a working integration: deploy the HTTP proxy, capture an API key through the web element, and proxy your first request with the secret resolved automatically.
What You’ll Build
Your Users Your Infrastructure Layr8┌────────────┐ ┌──────────────────────────┐ ┌──────────────────┐│ │ │ │ │ ││ Browser │ │ Your App (backend) │ │ Vault-Proxy ││ ┌──────┐ │ │ │ │ (HSM-backed ││ │ ZTG │──┼─────►│ POST /api/session │ │ encryption) ││ │Field │ │ │ ↓ │ │ ││ └──────┘ │ │ http-proxy (:9080) ────┼─────►│ Resolves tokens ││ │ │ │ │ Forwards to API │└────────────┘ └──────────────────────────┘ └────────┬─────────┘ │ ┌────────▼─────────┐ │ Target API │ │ (Stripe, etc.) │ └──────────────────┘The flow:
- Your user enters an API key in a secure input field (hosted by Layr8, isolated from your code)
- The key is encrypted and stored — your app receives only an opaque
tokenId - When your app needs the key, it sends requests through the http-proxy with
${tokenId}placeholders - Layr8 resolves the placeholder to the real key and forwards the request to the target API
Your application never handles plaintext secrets.
Prerequisites
- A Layr8 account with a provisioned cloud node
- From your node’s web console (open it from the Layr8 portal):
- Node WebSocket URL — e.g.
wss://yournode.layr8.cloud/plugin_socket/websocket - Node API key — create one in the console and configure the allowlist to include the vault-proxy DID or use
*during development
- Node WebSocket URL — e.g.
- Vault-proxy DID —
did:web:node01-keyvault.layr8.cloud:ztg-vault-proxy(same for all customers) - Vault-proxy URL (
apiOrigin) —https://ztg-vault-proxy.layr8.cloud(same for all customers). The SDK sends secrets to this URL for tokenization.
Step 1: Generate a Customer ID Salt
Layr8 identifies your customers by a salted_customer_id — a value your
backend computes from your internal customer ID. The requirements:
- Format: exactly 64 lowercase hex characters (
[a-f0-9]{64}) - How to produce it: SHA-256 hash of a secret salt concatenated with your customer ID
The salt prevents Layr8 from seeing your raw customer identifiers.
# Generate a salt once, store securely (e.g. in your secret manager)openssl rand -hex 32# → e.g. 7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0aStore this as ZTG_CUSTOMER_SALT in your application’s environment. Then in
your backend, compute the salted ID like this:
const saltedCustomerId = crypto .createHash("sha256") .update(ZTG_CUSTOMER_SALT + customerId) .digest("hex");// → "c8f3e2a1b4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1"// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^// 64 lowercase hex characters — this is what Layr8 receivesUse the same salt for all customers — consistency is what makes token IDs deterministic (enabling key rotation without code changes).
Important: If you lose the salt, existing salted_customer_id values
become unreproducible and stored tokens are inaccessible under the new IDs.
Step 2: Deploy the HTTP Proxy
The http-proxy runs in your infrastructure. Choose your deployment method:
Option A: Binary on a VM (EC2, bare metal)
# Download the binary (x86_64)curl -L https://downloads.layr8.io/ztg-http-proxy/latest/ztg-http-proxy-latest-linux-amd64.tar.gz | tar xz
# For ARM64 (e.g. AWS Graviton)curl -L https://downloads.layr8.io/ztg-http-proxy/latest/ztg-http-proxy-latest-linux-arm64.tar.gz | tar xzCreate your config file:
cat > config.yaml <<'EOF'mode: proxylisten: ":9080"target_url: "https://api.stripe.com"server_did: "did:web:node01-keyvault.layr8.cloud:ztg-vault-proxy"agent_did: "did:web:yournode.layr8.cloud:ztg-http-proxy"node: url: "wss://yournode.layr8.cloud/plugin_socket/websocket" api_key: "${NODE_API_KEY}"EOFRun it:
export NODE_API_KEY="your-node-api-key"./ztg-http-proxy -config config.yamlFor production, run it as a systemd service:
# Create a system usersudo useradd --system --no-create-home --shell /usr/sbin/nologin ztg-proxy
# Install binary and configsudo cp ztg-http-proxy /usr/local/bin/sudo mkdir -p /etc/ztg-http-proxysudo cp config.yaml /etc/ztg-http-proxy/config.yamlsudo chmod 600 /etc/ztg-http-proxy/config.yamlsudo chown ztg-proxy:ztg-proxy /etc/ztg-http-proxy/config.yaml
# Store the API key separatelysudo tee /etc/ztg-http-proxy/env > /dev/null <<'ENVEOF'NODE_API_KEY=your-node-api-keyENVEOFsudo chmod 600 /etc/ztg-http-proxy/envsudo chown ztg-proxy:ztg-proxy /etc/ztg-http-proxy/env
# Create the servicesudo tee /etc/systemd/system/ztg-http-proxy.service > /dev/null <<'SVCEOF'[Unit]Description=ZTG HTTP ProxyAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=ztg-proxyGroup=ztg-proxyExecStart=/usr/local/bin/ztg-http-proxy -config /etc/ztg-http-proxy/config.yamlEnvironmentFile=-/etc/ztg-http-proxy/envRestart=on-failureRestartSec=5NoNewPrivileges=trueProtectSystem=strictProtectHome=trueReadOnlyPaths=/etc/ztg-http-proxyPrivateTmp=true
[Install]WantedBy=multi-user.targetSVCEOF
# Start itsudo systemctl daemon-reloadsudo systemctl enable --now ztg-http-proxyOption B: Kubernetes (Helm)
helm install http-proxy oci://ghcr.io/layr8/charts/http-proxy \ --version 0.13.0 \ --namespace ztg --create-namespace \ --set configValues.mode=proxy \ --set configValues.listen=":9080" \ --set configValues.target_url="https://api.stripe.com" \ --set configValues.server_did="did:web:node01-keyvault.layr8.cloud:ztg-vault-proxy" \ --set configValues.node.url="wss://yournode.layr8.cloud/plugin_socket/websocket" \ --set configValues.node.api_key="\${NODE_API_KEY}"Verify the proxy is running
# VM: curl directlycurl http://localhost:9080/healthz
# Kubernetes: port-forward firstkubectl port-forward -n ztg svc/http-proxy 9080:9080 &curl http://localhost:9080/healthz
# → {"status":"ok"}Step 3: Add a Session Endpoint to Your Backend
Your backend creates short-lived session tokens by calling the http-proxy. The frontend SDK calls your endpoint with security keys, and your backend forwards them to the http-proxy to bind the session.
// Node.js exampleconst crypto = require("crypto");
const ZTG_PROXY_URL = process.env.ZTG_PROXY_URL || "http://localhost:9080";const ZTG_CUSTOMER_SALT = process.env.ZTG_CUSTOMER_SALT;
function saltCustomerId(customerId) { return crypto .createHash("sha256") .update(ZTG_CUSTOMER_SALT + customerId) .digest("hex");}
app.post("/api/ztg/session", async (req, res) => { const { publicKeys } = req.body; const saltedId = saltCustomerId(req.user.id);
const response = await fetch(`${ZTG_PROXY_URL}/ztg/tokenize-session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ salted_customer_id: saltedId, public_keys: publicKeys, }), });
const { session_token } = await response.json(); res.json({ sessionToken: session_token });});The public_keys field is required — it contains security keys that bind the session to the originating browser. Each secure iframe generates these keys internally, and the SDK passes them to your callback automatically.
The
saltCustomerIdfunction is a shared utility — you’ll reuse it in Step 5 when proxying API calls. Keep it in a shared module.
Step 4: Capture a Secret with the Web Element
Add the secure input field to your frontend. The user types their API key into an isolated iframe — your JavaScript never touches the secret.
Script Tag (No Build Step)
<script src="https://js.ztg.layr8.cloud/v1/ztg.js"></script>
<h2>Connect your Stripe account</h2><div id="api-key-field"></div><button id="save-btn">Save API Key</button><p id="status"></p>
<script> let ztg;
// Callback that creates sessions with security keys bound to the browser async function getSession(publicKeys) { const res = await fetch("/api/ztg/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKeys }), }); return (await res.json()).sessionToken; }
async function init() { ztg = ZTG.create("https://ztg-vault-proxy.layr8.cloud"); ztg.createField("#api-key-field", { placeholder: "sk_live_...", label: "Stripe Secret Key", }); }
document.getElementById("save-btn").onclick = async () => { // 1. Tokenize — the SDK calls getSession internally with security keys, // then sends the secret directly to Layr8, not your server const { tokenId } = await ztg.tokenize(getSession);
// 2. Store the tokenId in your database (it's safe — it's not the secret) await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tokenId, provider: "stripe" }), });
document.getElementById("status").textContent = "Key saved! Token: " + tokenId; };
init();</script>React
npm install @layr8/ztg-js @layr8/ztg-reactimport { useState } from "react";import { ZTGProvider, SecretField, useZTG } from "@layr8/ztg-react";import type { GetSession } from "@layr8/ztg-react";
// Callback that creates sessions with security keys bound to the browserconst getSession: GetSession = async (publicKeys) => { const res = await fetch("/api/ztg/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKeys }), }); return (await res.json()).sessionToken;};
function App() { return ( <ZTGProvider apiOrigin="https://ztg-vault-proxy.layr8.cloud"> <ApiKeyOnboarding /> </ZTGProvider> );}
function ApiKeyOnboarding() { const ztg = useZTG(); const [status, setStatus] = useState("");
const handleSave = async () => { const { tokenId } = await ztg.tokenize(getSession);
await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tokenId, provider: "stripe" }), });
setStatus(`Key saved! Token: ${tokenId}`); };
return ( <div> <h2>Connect your Stripe account</h2> <SecretField placeholder="sk_live_..." label="Stripe Secret Key" /> <button onClick={handleSave}>Save API Key</button> <p>{status}</p> </div> );}Step 5: Proxy API Calls with Token Placeholders
Now your backend can make API calls through the http-proxy. Replace the real
secret with a ${tokenId} placeholder, and include the X-Ztg-Customer-Id
header:
async function callStripeApi(userId, tokenId, params) { const saltedId = saltCustomerId(userId);
const response = await fetch(`${ZTG_PROXY_URL}/v1/charges`, { method: "POST", headers: { // The proxy resolves ${tokenId} to the real Stripe key Authorization: `Bearer \${${tokenId}}`, // Required: identifies which customer's tokens to resolve // Consumed by the vault-proxy, NOT forwarded to Stripe "X-Ztg-Customer-Id": saltedId, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(params).toString(), });
return response.json();}
// Usageconst charge = await callStripeApi(user.id, user.stripeTokenId, { amount: 2000, currency: "usd",});What happens:
- Your app sends the request to
http://localhost:9080/v1/charges - The http-proxy wraps it in a DIDComm message and sends it to the vault-proxy
- The vault-proxy resolves
${tokenId}to the real Stripe API key - The vault-proxy strips
X-Ztg-Customer-Idand forwards tohttps://api.stripe.com/v1/charges - The response comes back through the same path to your app
# Test with curlcurl -X POST http://localhost:9080/v1/charges \ -H "Authorization: Bearer \${tok_abc123}" \ -H "X-Ztg-Customer-Id: c8f3e2a1b4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1" \ -d "amount=2000¤cy=usd"What You’ve Built
Your application now handles third-party API keys without ever touching them:
- Users enter keys through a secure iframe isolated from your code
- Keys are encrypted with HSM-backed keys and stored in hardware-isolated infrastructure
- Your app stores
tokenIdreferences — safe, opaque strings - API calls route through the proxy — secrets are resolved at the last moment, inside Layr8’s secure infrastructure
Multiple APIs
Each http-proxy instance is configured with a single target_url. To call
multiple APIs, deploy one proxy per target:
| Instance | Port | Target |
|---|---|---|
| http-proxy-stripe | 9080 | https://api.stripe.com |
| http-proxy-openai | 9081 | https://api.openai.com |
Your application routes requests to the correct proxy. On a VM, run multiple systemd services with different config files and listen ports. On Kubernetes, install the Helm chart multiple times with different release names.
Note that services with multiple API base URLs (e.g. Google APIs with
googleapis.com/drive, googleapis.com/docs, etc.) currently require
separate proxy instances per base URL.
Known Limitations
One target_url per proxy instance. The proxy forwards all requests to a
single target API. If you call multiple external APIs, you need one proxy per
target. This adds operational overhead for services that integrate with many
APIs.
Reserved paths. The proxy reserves /healthz and /ztg/tokenize-session
for its own endpoints. If a target API uses these exact paths, the proxy’s
endpoints take priority and the target API paths are unreachable. In practice
this is unlikely, but worth noting.
The proxy runs as a separate service. It listens on its own port (default
:9080) and should not share a port with your application. Your backend sends
requests to the proxy’s address instead of directly to the target API.
Next Steps
- ZTG Web Element — Full API reference, multi-key forms, key rotation, security model
- Architecture — How Layr8’s infrastructure protects secrets end-to-end
- Boundaries & Assumptions — What Layr8 guarantees and what it doesn’t