#!/usr/bin/env python3 """List and create Roblox developer products with an Open Cloud API key, without ever printing the key. Usage: python3 devproducts.py list --universe python3 devproducts.py create --universe --spec products.json [--dry-run] The key is read from a file (default ~/.roblox_api_key, override with --key-file or $ROBLOX_KEY_FILE). The file must be chmod 600; the script refuses otherwise. The key is never taken from argv (it would land in shell history and `ps`), never printed, and never written into logs. products.json: [ {"name": "Coins Pack S", "description": "1,000 coins", "price": 49, "image": "icons/coins_s.png"}, ... ] Products whose name already exists in the universe are skipped, so re-running is safe. The output is a name -> productId map: paste it into your Products config (an unconfigured id stays 0 = inert). Endpoints (Open Cloud, header x-api-key): GET https://apis.roblox.com/developer-products/v2/universes/{universeId}/developer-products/creator?pageSize=50 (follow nextPageToken via &pageToken=...) POST https://apis.roblox.com/developer-products/v2/universes/{universeId}/developer-products multipart/form-data: name, description, price, isForSale, imageFile (optional) The key needs developer-product read + write for that universe (for a group game, create the key under the group). """ import argparse import json import mimetypes import os import stat import sys import urllib.error import urllib.request import uuid from pathlib import Path BASE = "https://apis.roblox.com/developer-products/v2/universes/{u}/developer-products" def read_key(path: str) -> str: p = Path(os.path.expanduser(path)) if not p.exists(): sys.exit(f"no key file at {p} (create it, then chmod 600)") mode = stat.S_IMODE(p.stat().st_mode) if mode & 0o077: sys.exit(f"{p} is readable by others (mode {oct(mode)}); run: chmod 600 {p}") key = p.read_text().strip() if not key: sys.exit(f"{p} is empty") return key def request(method, url, key, body=None, ctype=None): req = urllib.request.Request(url, data=body, method=method) req.add_header("x-api-key", key) if ctype: req.add_header("Content-Type", ctype) try: with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read() or b"{}") except urllib.error.HTTPError as e: # print the status and Roblox's message only; the request (and key) is never echoed sys.exit(f"{method} failed: HTTP {e.code} {e.read()[:400].decode('utf-8', 'replace')}") def list_products(universe, key): out, token = [], "" while True: url = BASE.format(u=universe) + "/creator?pageSize=50" + (f"&pageToken={token}" if token else "") page = request("GET", url, key) out += page.get("developerProducts", []) token = page.get("nextPageToken") if not token: return out def multipart(fields: dict, file_path=None): boundary = uuid.uuid4().hex parts = [] for k, v in fields.items(): parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()) if file_path: data = Path(file_path).read_bytes() mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream" parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="imageFile"; ' f'filename="{Path(file_path).name}"\r\nContent-Type: {mt}\r\n\r\n').encode() + data + b"\r\n") parts.append(f"--{boundary}--\r\n".encode()) return b"".join(parts), f"multipart/form-data; boundary={boundary}" def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("cmd", choices=["list", "create"]) ap.add_argument("--universe", required=True) ap.add_argument("--spec") ap.add_argument("--dry-run", action="store_true") ap.add_argument("--key-file", default=os.environ.get("ROBLOX_KEY_FILE", "~/.roblox_api_key")) a = ap.parse_args() key = read_key(a.key_file) existing = list_products(a.universe, key) by_name = {p.get("name"): p.get("productId") for p in existing} if a.cmd == "list": for p in existing: print(f'{p.get("productId")}\t{p.get("price") or p.get("priceInformation")}\t{p.get("name")}') return 0 if not a.spec: sys.exit("create needs --spec products.json") result = {} for spec in json.loads(Path(a.spec).read_text()): name = spec["name"] if name in by_name: result[name] = by_name[name] print(f"exists {name} -> {by_name[name]}") continue if a.dry_run: print(f"would create {name} ({spec['price']} Robux)") continue body, ctype = multipart({"name": name, "description": spec.get("description", ""), "price": str(int(spec["price"])), "isForSale": "true"}, spec.get("image")) made = request("POST", BASE.format(u=a.universe), key, body, ctype) result[name] = made.get("productId") print(f"created {name} -> {result[name]}") print(json.dumps(result, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())