# Self-hosted Tebex Bridge

> Run a small open-source proxy on your own server so your Tebex API keys never leave your infrastructure, while keeping every Tebex feature in Joely.

Source: https://joely.io/documentation/integrations/tebex-bridge
Last updated: 2026-10-06

By default, Joely stores your Tebex API keys encrypted on its servers (see [Tebex keys & security](https://joely.io/documentation/integrations/tebex-keys.md)). If you prefer your keys to **never leave your own infrastructure**, you can run the Tebex Bridge instead: a small, open-source proxy that holds your keys on your server. Joely then calls your bridge, never the Tebex API directly.

## How it works

Request flow:

- **Without a bridge (default):** Joely (your Tebex keys, encrypted) → Tebex API
- **With a self-hosted bridge:** Joely (no Tebex keys) → *HMAC-signed* → Your bridge (your Tebex keys, your server) → Tebex API

1. You run the bridge (a ~400-line Node.js server) on your own VPS or host. Your Tebex keys live in its `.env` file.
2. In Joely, you set your bridge URL and a shared secret on the store.
3. Every Tebex feature (packages, order lookup, purchase history, coupons, gift cards) now flows through your bridge. The bridge injects your Tebex keys and forwards the request to Tebex.
4. The bridge **strips buyer PII**: name, email, IP address, country, postal code, and the buyer's player profile are removed from payment and purchase-history responses before they ever reach Joely. Only what Joely displays passes through: transaction data and the buyer's webstore username.

The bridge source code is public and intentionally small so you can audit it yourself: every route it exposes mirrors the official Tebex API documentation, and any other path returns 404. It cannot be used as an arbitrary proxy.

## What the bridge sends to Joely

Joely only needs transaction data (ids, statuses, prices, currency, dates, and product names) plus the buyer's **webstore username** to label an order. It never needs the buyer's real-world identity. So before forwarding a Tebex response, the bridge runs a small JSON sanitizer that removes everything else. Three responses carry buyer PII and are cleaned:

**Order details** (Checkout API). The bridge keeps the transaction itself untouched (id, status, amounts, currency, fees, dates, product names) and reduces the buyer block to the single webstore username. Removed before sending: first and last name, email, IP address, country, postal code, marketing-consent flag, and any gift-recipient username on a line item. The buyer block is an **allowlist**: only the username can ever pass, so if Tebex adds a new identifying field tomorrow, it is dropped automatically.

**Purchase history** (Plugin API). The bridge keeps only the list of payments (each with its transaction id, date, price, currency, and status). Removed before sending: the buyer's player profile and their behaviour stats (ban count, chargeback rate, lifetime purchase totals).

**Payment lookup** (Plugin API), used for orders the Checkout API cannot return, such as manual payments. The bridge keeps the payment itself (id, amount, status, currency, payment method, packages, notes, dates) and reduces the player block to the webstore username. Removed before sending: email, IP address, and the player's platform id and UUID. A bridge released before this route answers 404: Joely then simply shows no details for such an order.

**Payment link baskets** (Headless and Checkout APIs), used when a customer pays a [payment link](https://joely.io/documentation/integrations/tebex-payment-links.md). A basket holds the buyer's email, name, address and IP, so the bridge keeps an allowlist only: the basket id, whether it is complete, the Cfx.re account the buyer logged in with (username and id, used to check it is the ticket author) and the package ids and names. On the Checkout side it keeps only whether the basket is paid and the link to its transaction. Everything else is removed before sending.

Every other request (store information, your package catalog, the login links of a payment link basket, and the coupons and gift cards Joely creates or revokes) carries no buyer PII and is forwarded as-is. The sanitizer only ever drops fields; it cannot add or expose more than Tebex returned.

## Setting it up

### 1. Deploy the bridge

Clone the [tebex-bridge repository](https://github.com/Joely-io/tebex-bridge), copy `.env.example` to `.env`, and fill in your Tebex keys:

| Variable | Required | Purpose |
|----------|----------|---------|
| `TEBEX_PUBLIC_KEY` | Yes | Package catalog and store info (Headless API) |
| `JOELY_SHARED_SECRET` | Yes | Authenticates Joely's requests (you generate it in step 2) |
| `TEBEX_GAME_SERVER_SECRET_KEY` | Optional | Purchase history, coupons, gift cards, and the details of orders the Checkout API cannot return (Plugin API) |
| `TEBEX_PRIVATE_KEY` | Optional | Order detail lookup (Checkout API); the store ID is resolved automatically from your public key |

Run it with Docker or Node 20+, and expose it over **HTTPS** (a reverse proxy like Caddy or a Cloudflare Tunnel works well); Joely rejects `http://` bridge URLs. The optional keys behave exactly like in Joely: leave one out and the features it powers are simply unavailable.

### 2. Connect it in Joely

You can connect a bridge in two places:

**During onboarding**: on the Tebex step, switch the toggle from **Tebex API keys** to **Self-hosted**, enter your bridge URL and shared secret, then continue. Joely checks that the bridge is reachable, that the shared secret matches, and reads the store identity through the bridge. Your project is created with the bridge already configured, with no Tebex key ever passing through the form.

**From settings**: in **Settings → Tebex**, click **Add store** and switch the toggle from **Tebex API keys** to **Self-hosted**:

1. Enter your bridge URL (for example `https://bridge.yourdomain.com`).
2. Click **Generate** to create the shared secret, and copy it into your bridge's `JOELY_SHARED_SECRET` environment variable.
3. Restart your bridge, then click **Test connection**. Joely checks that the bridge is reachable, that the shared secret matches, and shows which optional Tebex keys your bridge holds in the "What you unlock" checklist.
4. Click **Connect store**. The store is created with the bridge already configured.

The settings flow requires the Add Tebex stores permission. Stores using a bridge show a **Self-hosted** badge in settings.

The connection method is fixed at creation: to switch an existing store between API keys and a bridge, disconnect the store and reconnect it with the other method.

## Security model

- Every request from Joely to your bridge is signed with **HMAC-SHA256** (timestamp, method, path, and body), with a 5-minute anti-replay window. Requests without a valid signature are rejected.
- The shared secret is stored encrypted in Joely with the same envelope encryption as Tebex keys, and is never returned by the API after saving.
- Buyer PII is stripped by the bridge before responses leave your server; Joely never receives the removed fields. See [What the bridge sends to Joely](#what-the-bridge-sends-to-joely) for the exact kept-vs-removed breakdown per response.

## What you are responsible for

Self-hosting moves the keys, and the uptime, to you:

- **Availability:** if your bridge is down, real-time Tebex features (order lookup, purchase history, coupon and gift card creation, package refresh) fail for that store until it is back. Already-synced package data stays available, and stores with other connection methods keep working; only the bridge store is skipped. When this happens, your staff see a persistent "Bridge disconnected" notification on the dashboard naming the affected store, and customers on the portal see a notification asking them to contact you so you can reconnect it (both stay on screen until clicked). Point your monitoring at `GET /v1/health` on your bridge.
- **HTTPS:** the bridge must be served behind TLS; Joely refuses to save an `http://` bridge URL. The signature protects authenticity, not confidentiality.
- **Final address:** Joely calls the exact URL you saved and never follows a redirect from it, so enter the address your bridge answers on directly. The URL must also resolve to a public address: private, loopback and cloud metadata addresses are refused, both when you save it and on every request.
- **Updates:** when Joely ships new Tebex features, your bridge may need an update to expose the new routes. Joely checks your bridge version ahead of time (see [Keeping the bridge up to date](#keeping-the-bridge-up-to-date)). If an action still reaches a bridge without the route it needs, your staff see a persistent "Bridge update required" notification with the minimum bridge version, and the action is not applied; on the portal, customers are asked to contact your support team instead. Revoking a coupon, voiding a gift card, manual payments, payment links and the purchase history of a store without Tebex Plus require bridge 1.1.0 or later. With bridge 1.1.1 or later and a private key configured on the bridge, a payment link is taxed on the buyer's country; otherwise Tebex taxes it on the country of the bridge's server.

## Keeping the bridge up to date

Your bridge reports its version on its health endpoint. Joely checks it alongside the reachability check, when you open the dashboard or switch projects, then at most every five minutes while you navigate:

- **Update required:** the bridge is older than the minimum version every Joely feature needs, so some Tebex features fail on that store. Members with the Update Tebex stores permission see a popup on the dashboard listing the affected stores, the version each one runs and the version required, with a link to these instructions. Closing it hides it for that project until Joely requires a newer version; the reminders below stay visible.
- **Update available:** a newer bridge release exists, but everything works. No popup, only the reminders below.
- **Reminders:** the **Settings** entry of the sidebar and the **Tebex** settings tab show an arrow badge, and each self-hosted store card in **Settings → Tebex** shows its bridge version and status ("Update required", "Update available", or the version when it is up to date). An action that still needs attention, such as an unreachable bridge, takes precedence over the arrow badge.
- **Unknown version:** a reachable bridge that does not report a version predates version reporting and is treated as requiring an update. An unreachable bridge is only reported as unreachable.

The reachability and version checks on the dashboard run for members with the View integrations or Update Tebex stores permission, and so do the badges they drive. They only share each store's name, reachability and bridge version, never its bridge URL or secret.

To update, pull the latest version of the [tebex-bridge repository](https://github.com/Joely-io/tebex-bridge) on your server and restart it: `git pull && pnpm install && pnpm build && pnpm start` with Node, or rebuild and restart the container with Docker. Your `.env` is kept. Then edit the store in **Settings → Tebex** and click **Test connection**: the new version shows up right away.

## Bridge or default storage?

The default (keys stored encrypted by Joely) is the right choice for most shops: AES-256-GCM envelope encryption on hardened infrastructure is typically stronger than a `.env` file on a personal VPS. Choose the bridge when key custody matters to you, when you want to be able to say, and verify, that your Tebex keys never touch a third party's servers.
