Skip to content

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:

  1. Your user enters an API key in a secure input field (hosted by Layr8, isolated from your code)
  2. The key is encrypted and stored — your app receives only an opaque tokenId
  3. When your app needs the key, it sends requests through the http-proxy with ${tokenId} placeholders
  4. 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
  • Vault-proxy DIDdid: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.

Terminal window
# Generate a salt once, store securely (e.g. in your secret manager)
openssl rand -hex 32
# → e.g. 7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a

Store 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 receives

Use 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)

Terminal window
# 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 xz

Create your config file:

Terminal window
cat > config.yaml <<'EOF'
mode: proxy
listen: ":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}"
EOF

Run it:

Terminal window
export NODE_API_KEY="your-node-api-key"
./ztg-http-proxy -config config.yaml

For production, run it as a systemd service:

Terminal window
# Create a system user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin ztg-proxy
# Install binary and config
sudo cp ztg-http-proxy /usr/local/bin/
sudo mkdir -p /etc/ztg-http-proxy
sudo cp config.yaml /etc/ztg-http-proxy/config.yaml
sudo chmod 600 /etc/ztg-http-proxy/config.yaml
sudo chown ztg-proxy:ztg-proxy /etc/ztg-http-proxy/config.yaml
# Store the API key separately
sudo tee /etc/ztg-http-proxy/env > /dev/null <<'ENVEOF'
NODE_API_KEY=your-node-api-key
ENVEOF
sudo chmod 600 /etc/ztg-http-proxy/env
sudo chown ztg-proxy:ztg-proxy /etc/ztg-http-proxy/env
# Create the service
sudo tee /etc/systemd/system/ztg-http-proxy.service > /dev/null <<'SVCEOF'
[Unit]
Description=ZTG HTTP Proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ztg-proxy
Group=ztg-proxy
ExecStart=/usr/local/bin/ztg-http-proxy -config /etc/ztg-http-proxy/config.yaml
EnvironmentFile=-/etc/ztg-http-proxy/env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/etc/ztg-http-proxy
PrivateTmp=true
[Install]
WantedBy=multi-user.target
SVCEOF
# Start it
sudo systemctl daemon-reload
sudo systemctl enable --now ztg-http-proxy

Option B: Kubernetes (Helm)

Terminal window
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

Terminal window
# VM: curl directly
curl http://localhost:9080/healthz
# Kubernetes: port-forward first
kubectl 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 example
const 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 saltCustomerId function 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

Terminal window
npm install @layr8/ztg-js @layr8/ztg-react
import { 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 browser
const 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();
}
// Usage
const charge = await callStripeApi(user.id, user.stripeTokenId, {
amount: 2000,
currency: "usd",
});

What happens:

  1. Your app sends the request to http://localhost:9080/v1/charges
  2. The http-proxy wraps it in a DIDComm message and sends it to the vault-proxy
  3. The vault-proxy resolves ${tokenId} to the real Stripe API key
  4. The vault-proxy strips X-Ztg-Customer-Id and forwards to https://api.stripe.com/v1/charges
  5. The response comes back through the same path to your app
Terminal window
# Test with curl
curl -X POST http://localhost:9080/v1/charges \
-H "Authorization: Bearer \${tok_abc123}" \
-H "X-Ztg-Customer-Id: c8f3e2a1b4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1" \
-d "amount=2000&currency=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 tokenId references — 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:

InstancePortTarget
http-proxy-stripe9080https://api.stripe.com
http-proxy-openai9081https://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