DraxGUI webhooks and Tebex checkout

Updated 28 October 2026

Operator and developer guide for the two Store & Web logic nodes, Webhook (web.webhook) and Tebex checkout (web.tebex_checkout).

Everything below is covered by automated tests; nothing has been exercised against a live Tebex store or a public webhook receiver (section 8 lists what was and was not tested).

1. What the nodes do

Node Inputs Ports Effect
web.webhook endpoint (an id from web.webhooks in config.yml), body (map) in / next / failed POSTs the body as JSON to the endpoint's URL, optionally signed. next on any 2xx, failed otherwise.
web.tebex_checkout package (Tebex package ident or id), message (text, default "Click to buy") in / next / failed Creates a Tebex basket for the player, adds the package, and shows the player a clickable checkout link in chat. next once the link is shown.

Both nodes pause the flow while the request is in flight (like Wait / Call script), never block the server thread, and resume on the main thread. Every failure takes the failed port when it is wired (otherwise the flow ends) and writes a short reason to the debug trace (debug: true). Reasons are written for the operator and contain no URL path, query, token or secret. They are not shown to players.

If nothing is configured the nodes fail with a message saying what to configure; menus, HUDs, popups, packs and the editor are unaffected. The editor preview never sends a webhook or contacts Tebex ("would run" only).

A project can reference an endpoint by id only. URLs, secrets and tokens exist only in config.yml (or environment variables) and never in projects, exports or packs.

2. Configuration

Add a web: section to plugins/DraxGUI/config.yml. /draxgui reload applies changes (except the two worker limits, which need a restart).

web:
  limits:
    maxConcurrent: 4          # worker threads for outbound calls (restart to change)
    queueSize: 64             # queued calls before new ones fail immediately (restart to change)
    maxBodyBytes: 65536       # upper bound for any webhook body
  egress:
    # Names / IPs / CIDRs that may resolve to private or loopback addresses (see section 3.2).
    allowHosts: []
  webhooks:
    rewards:
      url: https://hooks.example.com/draxgui/rewards
      secretEnv: DRAXGUI_REWARDS_SECRET     # HMAC-SHA256 signing key from the environment (or: secret: "...")
      timeoutMs: 5000
      retries: 0
      includePlayer: false
  tebex:
    enabled: false
    publicTokenEnv: TEBEX_PUBLIC_TOKEN      # or: publicToken: "xxxx-..."
    # privateKeyEnv: TEBEX_PRIVATE_KEY      # optional, see section 6

web.limits

Key Default Range Meaning
maxConcurrent 4 1-16 Worker threads shared by all outbound calls.
queueSize 64 1-1024 Calls waiting for a worker. A full queue fails the call immediately; the server thread never waits.
maxBodyBytes 65536 256-262144 Hard cap on any webhook body; an endpoint can only lower it.

web.egress

Key Meaning
allowHosts Host names (hooks.corp.example, *.lab.example = subdomains only), IP addresses (10.0.0.5, [::1]) or CIDRs (10.0.0.0/8, fd00::/8) the policy exempts. See section 3.2 for exactly what each kind unlocks.

web.webhooks.<id>

The id may use letters, digits, _, ., - (max 64). A bad endpoint is disabled with a reason; the others keep working.

Key Default Range Meaning
url required https://... (or http:// with allowInsecureHttp). No credentials in the URL. Treat the whole URL as a secret when the receiver puts a token in the path (Discord, Slack): it is stored only here and logged nowhere.
secret / secretEnv none HMAC-SHA256 signing key, literal or the name of an environment variable (not both). Without one the request is unsigned. A key under 16 characters is accepted but warned about.
timeoutMs 5000 200-30000 Deadline for one attempt, covering DNS, connect, TLS, writing and reading.
maxResponseBytes 65536 256-1048576 Responses larger than this fail the node (and are not retried).
maxBodyBytes min(16384, limits) 64-262144 Request body cap for this endpoint.
retries 0 0-3 Extra attempts for the transient failures in section 4.
includePlayer false Adds "player": {"name": ..., "uuid": ...} to the body. A project body that already has a player key is then rejected (it could spoof the identity).
allowPrivate false Lets this endpoint's own host resolve to loopback / private / CGNAT addresses. Never covers metadata, link-local, multicast or unspecified addresses.
allowInsecureHttp false Permit http:// (also for redirects of this endpoint).
followCrossHostRedirects false Follow a 307/308 to another host (still policy-checked). Never https to http.
maxRedirects 2 0-5 Redirect hops followed.
minIntervalMs 0 0-3600000 Minimum time between calls by the same player.
maxPending 8 1-64 Queued + running calls for this endpoint; more fail immediately.

web.tebex

Key Default Meaning
enabled false Master switch.
adapter headless-v1 Adapter revision (section 6). Unknown values disable Tebex with a message.
publicToken / publicTokenEnv required The store's public token (Tebex panel > Developers > API keys). Letters, digits, -, _, 8-128 characters; it becomes part of the request path.
privateKey / privateKeyEnv none Optional; sent as HTTP Basic credentials when set (section 6).
baseUrl https://headless.tebex.io/api Same egress rules as webhooks.
createBasketPath / addPackagePath /baskets / /baskets/{basket}/packages Path templates under {baseUrl}/accounts/{publicToken}; escape hatch if Tebex changes a path (section 6).
checkoutHosts tebex.io, *.tebex.io Hosts a checkout link may point to. Anything else is dropped.
allowPackages empty (any) If set, only these package idents can be offered.
minIntervalMs 5000 Minimum time between checkout requests by one player.
timeoutMs / retries / maxResponseBytes / maxPending 8000 / 1 / 262144 / 8 As for webhooks.
sendUsername true Sends the player's name when creating the basket (Minecraft stores need it).
completeUrl / cancelUrl empty Optional return links (http(s) only).
allowPrivate / allowInsecureHttp false For testing against a local mock only.

Unknown keys are reported as problems at load time (typos are not ignored silently). /draxgui status shows each endpoint's scheme://host, whether it is signed, the Tebex state and all configuration problems, never a secret or URL path.

3. Security model

3.1 Who controls what

Decision Who Where
Which URLs may be called, with which secret Server administrator config.yml / environment
Which endpoint a menu uses, what the body contains Project author (editor users) project JSON, by endpoint id
Whether player name/UUID is sent Administrator (includePlayer) config.yml
Which Tebex package a menu offers Project author, optionally restricted by allowPackages project JSON + config.yml
Whether a purchase happened Tebex, through its own delivery not DraxGUI

A response from a webhook receiver is read (bounded) but never interpreted: it cannot set variables, grant items or run commands. A Tebex response is parsed only for the basket id and the checkout link.

3.2 Egress policy (SSRF)

Every destination is checked on the resolved addresses, not on the text of the URL:

Class Examples Default What can unlock it
public any global unicast allowed
loopback 127.0.0.0/8, ::1 blocked host name in allowHosts, allowPrivate on that endpoint, or an IP/CIDR entry
private 10/8, 172.16/12, 192.168/16, fc00::/7 blocked same
CGNAT 100.64.0.0/10 blocked same
link-local 169.254.0.0/16, fe80::/10, fec0::/10 blocked IP/CIDR entry only
cloud metadata 169.254.169.254, 169.254.170.2, 100.100.100.200, 192.0.0.192, fd00:ec2::254 blocked an entry naming that exact address (IP or CIDR)
multicast, unspecified, broadcast/reserved, documentation/benchmark ranges, Teredo 224/4, 0.0.0.0, 240/4, 192.0.2.0/24, 2001::/32, 2001:db8::/32, ... blocked IP/CIDR entry only

IPv6 forms that carry an IPv4 address (IPv4-mapped ::ffff:a.b.c.d, NAT64 64:ff9b::/96, 6to4 2002::/16) are judged by the embedded IPv4 address, so ::ffff:127.0.0.1 or 64:ff9b::a9fe:a9fe do not bypass the rules. Allowlisting a name (or allowPrivate) only ever unlocks loopback, private and CGNAT addresses for that name; it can never reach metadata or link-local addresses, even if that name later resolves there.

Name and URL rules applied before any DNS lookup: only http/https; http needs allowInsecureHttp; no user:password@; ASCII host names only; IP literals must be canonical (2130706433, 0x7f.0.0.1, 127.1, 010.0.0.1 are refused); localhost, single-label names and *.local, *.localhost, *.localdomain, *.internal, *.home.arpa are refused unless allowlisted. Literal IPs in url / baseUrl are also checked when the configuration loads, so an obviously blocked endpoint is reported at startup.

If a name resolves to several addresses, all of them must be acceptable; one private answer among public ones refuses the host.

3.3 Connection-time validation (DNS rebinding)

java.net.http.HttpClient resolves the host again when it connects and offers no hook between the lookup and the connection, so an address check done first can be defeated by a name that answers differently the second time. DraxGUI therefore uses a small purpose-built HTTP/1.1 client (SafeHttp):

  1. check the URL syntax and name rules;
  2. resolve the name once (on a bounded resolver pool, inside the call deadline);
  3. require every returned address to pass the policy;
  4. connect a socket to those exact InetAddress objects (no second lookup); for https, layer TLS on that socket with SNI and certificate verification (HTTPS endpoint identification) for the name in the URL;
  5. repeat steps 1-4 for every redirect hop.

The tests prove that the resolver is asked exactly once per hop, that the connection goes to the validated address, and that a name which answers a metadata address on the second lookup (the redirect hop) is refused without any connection. A mutation that connects by name instead of by address makes that test fail.

3.4 Redirects

Only 307 and 308 are followed (they keep the POST method and body). 301, 302 and 303 fail the node because following them would silently turn the delivery into a body-less GET. Each hop is limited by maxRedirects, re-validated like a first request, may not leave the endpoint's host unless followCrossHostRedirects is true, and may never go from https to http.

3.5 Secrets, logging and privacy

  • Secrets may be written in config.yml or supplied through environment variables (secretEnv, publicTokenEnv, privateKeyEnv); protect config.yml like any file holding credentials. Nothing is embedded in the plugin, in projects or in exported packs.
  • A missing or invalid secret disables only that endpoint: DraxGUI never sends an endpoint unsigned because its secret failed to load.
  • Messages, the trace and statusLines() show only scheme://host[:port], endpoint ids and reasons. Request/response bodies, paths, queries, tokens, keys and signatures are not logged. Configuration warnings contain setting names, never values.
  • Player name and UUID leave the server only when the endpoint sets includePlayer: true (webhooks) and, for Tebex, the player name that Tebex needs to attribute the basket. The Tebex call sends no UUID or IP address.
  • The body is authored by whoever can edit the project. Treat it as that editor's data when you decide whether an endpoint should receive it.

3.6 Bounded resources

All network work runs on maxConcurrent daemon worker threads behind a queueSize queue; a full queue, a per-endpoint maxPending overflow, a minIntervalMs violation or an invalid body fail the node immediately on the calling thread without I/O. One attempt has a single deadline across DNS, connect, TLS handshake, request write and response read (a server that drips bytes cannot extend it). A call as a whole, including retries, has an overall deadline, so a flow can never wait forever; closing the plugin completes every pending call with a failure. Response headers are capped (100 fields, 32 KiB), bodies at maxResponseBytes, JSON bodies at maxBodyBytes, 8 nesting levels and 10,000 values.

3.7 Residual risks and honest limits

  • Classification is a list. Future special-use ranges that are not in EgressPolicy.classify would count as public. Keep a network-level egress firewall as defence in depth.
  • Allowlists are trust. A name in allowHosts may resolve to any loopback/private/CGNAT address at any time; an IP/CIDR entry (including metadata addresses) is honoured as written.
  • Public receivers are public. The policy stops DraxGUI from being pointed at internal services. It cannot stop an administrator-configured public endpoint (or a service that becomes compromised) from receiving the body you send it.
  • Trust anchors are the JVM's default trust store; there is no certificate pinning. No client certificates.
  • No proxy support. HTTP(S)_PROXY and JVM proxy settings are ignored; an environment that forces outbound traffic through a proxy needs a firewall rule or a custom transport.
  • Hand-written HTTP/1.1 client. It is deliberately small (no HTTP/2, no compression, no keep-alive, strict framing that rejects ambiguous responses). Receivers that only speak HTTP/2 are not supported. It has been tested against the JDK's own HttpServer/HttpsServer and byte-exact malformed responses, not against a large corpus of real servers.
  • Resolver behaviour. The system resolver (including search domains and the JVM's DNS cache) decides what a name resolves to; the policy then judges that answer. A hung resolver costs one of two resolver threads until the OS gives up; further lookups queue up to 16 and then fail with "resolver busy".
  • Replay and duplicates are the receiver's job. DraxGUI signs a timestamp with the body and sends an idempotency key; the receiver must check freshness and deduplicate (sections 4 and 5).
  • IDN host names must be written in punycode.
  • The browser editor preview and the Bedrock fallback never call these nodes; there is no "live test" command yet.

4. Retry and idempotency policy

Every web.webhook execution gets a random Idempotency-Key (UUID) and a body fixed at that moment. Retries reuse the identical bytes and the identical key, plus X-DraxGUI-Attempt: 1, 2, ....

Retried (only if retries > 0 and attempts remain), after exponential backoff (250 ms base, jitter up to 25%, capped at 5 s):

Situation Why it is safe
DNS failure, connection refused / unreachable, TLS handshake failure, connect timeout no request byte was sent
HTTP 408, 429, 502, 503, 504 the receiver or a gateway says it did not process the request; a numeric Retry-After is honoured up to 5 s, a longer value gives up

Never retried: policy refusals, oversized or malformed responses, redirect problems, 500 and every other status, 4xx (except 408/429), and a timeout or connection error after the request was sent (the receiver may already be acting on it).

Consequences:

  • Delivery is at-least-once only for the listed transient cases and otherwise at-most-once per node execution. The receiver must deduplicate on Idempotency-Key if it matters.
  • Two separate executions (a double click, a reloaded flow) have different keys. Guard reward-like flows with a cooldown / once-per-player variable in the graph or minIntervalMs; a webhook response can never grant a reward by itself.
  • A failed outcome does not prove the receiver did nothing (for example after a timeout).

5. Verifying a signed webhook (receiver side)

Headers: X-DraxGUI-Timestamp (Unix seconds), X-DraxGUI-Signature: v1=<hex>, Idempotency-Key. The MAC is HMAC-SHA256(secret, timestamp + "." + rawBody). Compute it over the raw bytes you received, compare in constant time, and reject timestamps outside your tolerance (for example 5 minutes).

import hmac, hashlib, time

def verify(secret: bytes, headers, raw_body: bytes, tolerance=300) -> bool:
    ts = headers["X-DraxGUI-Timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False
    expected = "v1=" + hmac.new(secret, ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, headers["X-DraxGUI-Signature"])
const crypto = require('crypto');
function verify(secret, headers, rawBody, tolerance = 300) {
  const ts = headers['x-draxgui-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
  const mac = 'v1=' + crypto.createHmac('sha256', secret).update(ts + '.').update(rawBody).digest('hex');
  const a = Buffer.from(mac), b = Buffer.from(headers['x-draxgui-signature'] || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The body is a JSON object: your map input, with whole numbers written without a fraction (5, not 5.0). A nested map is written as an object; a nested list that happens to consist only of two-element [text, value] pairs with distinct keys is also written as an object, because the runtime represents maps that way (an empty nested map is therefore []). Error values in the body (a failed expression) make the node fail instead of sending null.

6. Tebex

6.1 What DraxGUI does

Adapter headless-v1 uses Tebex's documented Headless API (https://docs.tebex.io/developers/headless-api, read 2026-10-21):

  1. POST {baseUrl}/accounts/{publicToken}/baskets with {"username": "<player>"} (Minecraft stores need the username; optional complete_url / cancel_url). The response's data.ident is the basket.
  2. POST {baseUrl}/accounts/{publicToken}/baskets/{ident}/packages with {"package_id": "<ident or id>", "quantity": 1}. The response basket carries data.links.checkout.
  3. The link must be https (http only with allowInsecureHttp), without credentials, on the default port and on a host matching checkoutHosts; otherwise it is dropped and the node fails. The player then receives one line of plain text (formatting codes and control characters stripped, 128 characters max) with an open_url click and a hover naming the destination host. Vanilla clients ask the player to confirm before opening a chat link (not verified on a client here).

The basket and package idents are validated ([A-Za-z0-9_-]) before they become part of a path, so a hostile response cannot redirect the second call. Recommended package id: the ident (for example pkg_12_456); Tebex documents that idents work for every package type and that standard packages also accept the numeric id.

6.2 What DraxGUI does not do

  • It never asks for or sees card details; payment happens on Tebex's checkout page in the player's browser.
  • It never marks anything as purchased or grants anything. Fulfilment is Tebex's own delivery (the Tebex game-server plugin / command queue). A menu click, a "success" string, a link having been shown, or any client-supplied value is not proof of payment.
  • The private key is not needed for the basket calls (the documentation lists them as public-token calls). If you set one it is sent as HTTP Basic credentials (public token = user name, private key = password) on both calls; leaving it unset is the least-privilege choice.

6.3 Setup

  1. In the Tebex creator panel create or open your webstore (Minecraft store type) and note the packages you want to offer; copy each package's ident from the Headless API or the package page.
  2. Panel > Developers > API keys: copy the public token. Put it in an environment variable (TEBEX_PUBLIC_TOKEN) or web.tebex.publicToken.
  3. Set web.tebex.enabled: true, then /draxgui reload. /draxgui status should show tebex: ready (headless-v1).
  4. Use a Tebex checkout node with the package ident in a button flow. Tebex's "Test Payments" payment method (panel > Settings > Checkout > Test Mode) is the documented way to test; test payments create real records and webhook events, and must be removed before the store is public.
  5. Make sure Tebex's own game-server delivery plugin is installed and linked; that is what hands the player the product after payment.

6.4 Not verified live (read before relying on it)

No call to Tebex was made from this implementation (no account, no credentials, no money). The adapter follows the documentation but these points are unverified against the real service:

  • the exact add-package path: the Tebex guide text shows POST https://headless.tebex.io/api/baskets/{basketIdent}/packages while its OpenAPI excerpt lists {server}/{basketIdent}/packages under /accounts/{token}. DraxGUI uses /accounts/{token}/baskets/{basket}/packages; if Tebex answers 404 use addPackagePath to match your store;
  • whether username is accepted in the create-basket body (the guide requires it for Minecraft stores but its schema omits it), and whether Tebex accepts Floodgate/Bedrock-style names (leading . or *);
  • the real shape of links.checkout (host names other than *.tebex.io; extend checkoutHosts if needed);
  • rate limits (not documented on the pages read), the behaviour of 429 and Retry-After;
  • chat-link presentation on real clients (the component is built and unit-tested; no client has displayed it).

If a response does not look as expected the node fails and the trace names the stage ("creating the basket" / "adding the package") and the HTTP status; the title of Tebex's RFC 9457 problem document is included, detail is not.

6.5 Versioning

adapter: headless-v1 is the only supported revision. If Tebex changes its API, a new revision is added next to it (headless-v2) and v1 keeps working for as long as Tebex serves it; the path templates are an operator-level escape hatch for small path changes only.

7. Node reference: failure messages

Message (prefix) Meaning Fix
no webhook endpoints are configured: add web.webhooks.<id> web.webhooks is empty add the endpoint
unknown webhook endpoint '<id>' (configured: ...) typo in the node fix the id
webhook endpoint '<id>' is disabled by a configuration error: <reason> the endpoint failed validation see the reason and /draxgui status
webhook '<id>' is rate limited for this player minIntervalMs wait / adjust
webhook '<id>': body ... body too large, too deep, has an error value, or defines player with includePlayer fix the body
webhook '<id>' failed: destination ... resolves to a ... address egress policy section 3.2
... failed: HTTP <status> [after N attempts] the receiver answered non-2xx check the receiver
... failed: timed out ..., could not connect ..., TLS handshake ... failed, DNS lookup failed network problem check URL / certificate / firewall
... failed: response larger than maxResponseBytes the receiver sent too much raise the limit or fix the receiver
Tebex checkout is not configured / disabled / misconfigured: ... no usable web.tebex section 6.3
invalid Tebex package id / package '<id>' is not listed in web.tebex.allowPackages bad or disallowed package fix the node
Tebex rejected creating the basket (HTTP 4xx: <title>) / ... adding the package ... Tebex refused check token, store type and package ident
Tebex checkout link refused: ... the returned link failed validation check checkoutHosts
player is no longer online; checkout link not shown / session ended before ... the player left, or the menu session ended none
too many requests already pending for this endpoint / web request queue is full overload raise limits or reduce usage

8. What was tested

Covered by automated tests: the address checks and allowlists, the HTTP and TLS transport (framing, size limits, deadlines, redirects, certificate checks), the Webhook node (headers, bodies, signing, retries, limits), the Tebex flow against a mock of Tebex's documented endpoints, the configuration (including the shipped web: example) and the node flow in the logic engine.

Not tested: a live Tebex store, a public webhook receiver, a Minecraft client showing the chat link, a Bedrock player, a JVM behind a system proxy, very large traffic.