diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 682b64a..5b9ac2d 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -40,6 +40,7 @@ jobs:
run: |
# Exclude redoc HTML from diff check (output varies by platform due to styled-components)
git checkout -- docs/gen/redoc.v1.html 2>/dev/null || true
+ git checkout -- docs/gen/redoc.ingress.v1.html 2>/dev/null || true
if [ -n "$(git status --porcelain)" ]; then
echo "::error::Generated files are out of date. Please run 'make check' locally and commit the changes."
diff --git a/docs/config/swagger-merger-ingress-config.json b/docs/config/swagger-merger-ingress-config.json
new file mode 100644
index 0000000..d0947f0
--- /dev/null
+++ b/docs/config/swagger-merger-ingress-config.json
@@ -0,0 +1,31 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "title": "Stash Managed Catalog API",
+ "description": "Read-only API for querying the Stash managed product catalog. Designed for server-to-server communication between partner backends and Stash services.\n\n## Base URLs\n\n| Environment | Base URL |\n|---|---|\n| **Test** | `https://test-api.stash.gg` |\n| **Production** | `https://api.stash.gg` |\n\nAll endpoint paths below are relative to the base URL. For example, to list products in the test environment:\n\n```\nGET https://test-api.stash.gg/api/v1/studio/{shop_id}/products\n```\n\n## Authentication\n\nAll requests must include an HMAC-SHA256 signature in the `stash-hmac-signature` header.\n\n### Setup\n\n1. Go to **Stash Studio > Project Settings > API Secrets**\n2. Click **Generate Secret** to create a new key\n3. Keys created through this portal are Ingress keys by default and work with this API (this is the same key used for `/sdk/` API authentication)\n4. Store the secret securely — you will need it to sign every request\n\n### Signing a Request\n\n1. For GET requests (no body), sign an **empty string** using HMAC-SHA256 with your API secret\n2. Base64-encode the resulting signature\n3. Send it in the `stash-hmac-signature` HTTP header\n\n### Example (curl)\n\n```bash\nSIGNATURE=$(echo -n \"\" | openssl dgst -sha256 -hmac \"YOUR_API_SECRET\" -binary | base64)\ncurl -H \"stash-hmac-signature: $SIGNATURE\" \\\n \"https://test-api.stash.gg/api/v1/studio/{shop_id}/products\"\n```",
+ "version": "1.0.0",
+ "contact": {
+ "name": "API Support",
+ "url": "https://docs.stash.gg/",
+ "email": "help@stash.gg"
+ }
+ },
+ "schemes": ["https"],
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": [
+ {
+ "name": "ManagedCatalog",
+ "description": "Read-only endpoints for querying the managed product catalog"
+ }
+ ],
+ "paths": {
+ "$ref": "../../gen/openapiv2/server/ingress/studio/v1/service.swagger.json#paths"
+ },
+ "definitions": {
+ "$ref": "../../gen/openapiv2/server/ingress/studio/v1/service.swagger.json#definitions"
+ },
+ "securityDefinitions": {
+ "$ref": "../../gen/openapiv2/server/ingress/server.swagger.json#securityDefinitions"
+ }
+}
diff --git a/docs/gen/redoc.ingress.v1.html b/docs/gen/redoc.ingress.v1.html
new file mode 100644
index 0000000..cd97840
--- /dev/null
+++ b/docs/gen/redoc.ingress.v1.html
@@ -0,0 +1,515 @@
+
+
+
+
+
+ Stash Managed Catalog API
+
+
+
+
+
+
+
+
+
+ Stash Managed Catalog API (1.0.0) Download OpenAPI specification:
Read-only API for querying the Stash managed product catalog. Designed for server-to-server communication between partner backends and Stash services.
+
+
+
+Environment
+Base URL
+
+
+
+Test
+https://test-api.stash.gg
+
+
+Production
+https://api.stash.gg
+
+
+
All endpoint paths below are relative to the base URL. For example, to list products in the test environment:
+
GET https: / / test- api. stash. gg/ api/ v1/ studio/ { shop_id} / products
+
+
All requests must include an HMAC-SHA256 signature in the stash-hmac-signature header.
+
Setup
+
+Go to Stash Studio > Project Settings > API Secrets
+Click Generate Secret to create a new key
+Keys created through this portal are Ingress keys by default and work with this API (this is the same key used for /sdk/ API authentication)
+Store the secret securely — you will need it to sign every request
+
+
Signing a Request
+
+For GET requests (no body), sign an empty string using HMAC-SHA256 with your API secret
+Base64-encode the resulting signature
+Send it in the stash-hmac-signature HTTP header
+
+
Example (curl)
+
SIGNATURE = $( echo -n "" | openssl dgst -sha256 -hmac "YOUR_API_SECRET" -binary | base64)
+curl -H "stash-hmac-signature: $SIGNATURE " \
+ "https://test-api.stash.gg/api/v1/studio/{shop_id}/products"
+
+
Read-only endpoints for querying the managed product catalog
+
List published products Retrieves a paginated list of published products for a shop. Results are ordered by creation date (most recent first). Only published products are returned; drafts and deleted products are excluded.
+
path Parameters shopId required
string
Shop identifier from Stash Studio
+
query Parameters limit integer <int64>
Maximum results per page. Defaults to 50. Maximum allowed value is 100.
+
offset integer <int64>
Pagination offset. Use next_offset from the previous response to fetch the next page. Defaults to 0.
+
Responses default An unexpected error response.
+
get /api/v1/studio/{shopId}/products
/api/v1/studio/{shopId}/products
Response samples Content type application/json
Copy
Expand all Collapse all { "products" :
[ { "guid" : "string" ,
"productId" : "string" ,
"status" : "PRODUCT_STATUS_PUBLISHED" ,
"createdAt" : "2019-08-24T14:15:22Z" ,
"updatedAt" : "2019-08-24T14:15:22Z" ,
} ] , "nextOffset" : 0
}
Get a single published product Retrieves a single published product by its GUID. Returns NOT_FOUND if the product does not exist or is not published.
+
path Parameters shopId required
string
Shop identifier from Stash Studio
+
guid required
string
Stash-internal unique product identifier (UUID)
+
Responses default An unexpected error response.
+
get /api/v1/studio/{shopId}/products/{guid}
/api/v1/studio/{shopId}/products/{guid}
Response samples Content type application/json
Copy
Expand all Collapse all { "product" :
{ "guid" : "string" ,
"productId" : "string" ,
"status" : "PRODUCT_STATUS_PUBLISHED" ,
"createdAt" : "2019-08-24T14:15:22Z" ,
"updatedAt" : "2019-08-24T14:15:22Z" ,
} }
+
+
+
+
diff --git a/docs/gen/swagger.ingress.v1.json b/docs/gen/swagger.ingress.v1.json
new file mode 100644
index 0000000..174d29c
--- /dev/null
+++ b/docs/gen/swagger.ingress.v1.json
@@ -0,0 +1,1391 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "title": "Stash Managed Catalog API",
+ "description": "Read-only API for querying the Stash managed product catalog. Designed for server-to-server communication between partner backends and Stash services.\n\n## Base URLs\n\n| Environment | Base URL |\n|---|---|\n| **Test** | `https://test-api.stash.gg` |\n| **Production** | `https://api.stash.gg` |\n\nAll endpoint paths below are relative to the base URL. For example, to list products in the test environment:\n\n```\nGET https://test-api.stash.gg/api/v1/studio/{shop_id}/products\n```\n\n## Authentication\n\nAll requests must include an HMAC-SHA256 signature in the `stash-hmac-signature` header.\n\n### Setup\n\n1. Go to **Stash Studio > Project Settings > API Secrets**\n2. Click **Generate Secret** to create a new key\n3. Keys created through this portal are Ingress keys by default and work with this API (this is the same key used for `/sdk/` API authentication)\n4. Store the secret securely — you will need it to sign every request\n\n### Signing a Request\n\n1. For GET requests (no body), sign an **empty string** using HMAC-SHA256 with your API secret\n2. Base64-encode the resulting signature\n3. Send it in the `stash-hmac-signature` HTTP header\n\n### Example (curl)\n\n```bash\nSIGNATURE=$(echo -n \"\" | openssl dgst -sha256 -hmac \"YOUR_API_SECRET\" -binary | base64)\ncurl -H \"stash-hmac-signature: $SIGNATURE\" \\\n \"https://test-api.stash.gg/api/v1/studio/{shop_id}/products\"\n```",
+ "version": "1.0.0",
+ "contact": {
+ "name": "API Support",
+ "url": "https://docs.stash.gg/",
+ "email": "help@stash.gg"
+ }
+ },
+ "schemes": [
+ "https"
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ {
+ "name": "ManagedCatalog",
+ "description": "Read-only endpoints for querying the managed product catalog"
+ }
+ ],
+ "paths": {
+ "/api/v1/studio/{shopId}/products": {
+ "get": {
+ "summary": "List published products",
+ "description": "Retrieves a paginated list of published products for a shop. Results are ordered by creation date (most recent first). Only published products are returned; drafts and deleted products are excluded.",
+ "operationId": "ManagedCatalogService_ListProducts",
+ "responses": {
+ "200": {
+ "description": "A successful response.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "products": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "guid": {
+ "type": "string",
+ "description": "Stash-internal unique product identifier (UUID)"
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product name",
+ "title": "Localizable Text"
+ },
+ "productId": {
+ "type": "string",
+ "description": "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"
+ },
+ "description": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product description. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "images": {
+ "type": "object",
+ "properties": {
+ "mainImage": {
+ "type": "string",
+ "description": "Primary product image URL. Always non-empty for published products"
+ },
+ "backgroundImage": {
+ "type": "string",
+ "description": "Background image URL"
+ }
+ },
+ "description": "Product images",
+ "title": "Images"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_STATUS_UNSPECIFIED",
+ "PRODUCT_STATUS_PUBLISHED"
+ ],
+ "default": "PRODUCT_STATUS_PUBLISHED",
+ "description": "Product status. Always PRODUCT_STATUS_PUBLISHED for this API",
+ "example": "PRODUCT_STATUS_PUBLISHED"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was created (RFC 3339)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was last updated (RFC 3339)"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Human-readable display name. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "prices": {
+ "type": "array",
+ "example": [
+ {
+ "currency": "USD",
+ "platform": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "region": "US",
+ "cents": 499,
+ "updatedAt": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"
+ },
+ "platform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "description": "Platform (e.g., UNIVERSAL)",
+ "example": "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ },
+ "region": {
+ "type": "string",
+ "description": "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"
+ },
+ "cents": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Price in smallest currency unit (e.g., 499 for $4.99)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when this price was last updated (RFC 3339)"
+ }
+ },
+ "description": "A price entry for a product",
+ "title": "Price"
+ },
+ "description": "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
+ },
+ "items": {
+ "type": "array",
+ "example": [
+ {
+ "image": "https://example.com/coin.png",
+ "quantity": "100",
+ "displayName": {
+ "defaultText": "Gold Coin"
+ }
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "description": "URL to the item image. Always non-empty for published products"
+ },
+ "quantity": {
+ "type": "string",
+ "description": "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Optional display name for the item (max 50 characters)",
+ "title": "Localizable Text"
+ }
+ },
+ "description": "An item contained in a product",
+ "title": "Item"
+ },
+ "description": "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
+ }
+ },
+ "description": "A published product from the managed catalog",
+ "title": "Product"
+ },
+ "description": "List of published products"
+ },
+ "nextOffset": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Offset for the next page. Omitted if there are no more results."
+ }
+ },
+ "description": "Paginated list of published products",
+ "title": "List Products Response"
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "message": {
+ "type": "string"
+ },
+ "details": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "@type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": {}
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "shopId",
+ "description": "Shop identifier from Stash Studio",
+ "in": "path",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "name": "limit",
+ "description": "Maximum results per page. Defaults to 50. Maximum allowed value is 100.",
+ "in": "query",
+ "required": false,
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "name": "offset",
+ "description": "Pagination offset. Use next_offset from the previous response to fetch the next page. Defaults to 0.",
+ "in": "query",
+ "required": false,
+ "type": "integer",
+ "format": "int64"
+ }
+ ],
+ "tags": [
+ "ManagedCatalog"
+ ],
+ "security": [
+ {
+ "hmac": []
+ }
+ ]
+ }
+ },
+ "/api/v1/studio/{shopId}/products/{guid}": {
+ "get": {
+ "summary": "Get a single published product",
+ "description": "Retrieves a single published product by its GUID. Returns NOT_FOUND if the product does not exist or is not published.",
+ "operationId": "ManagedCatalogService_GetProduct",
+ "responses": {
+ "200": {
+ "description": "A successful response.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "product": {
+ "type": "object",
+ "properties": {
+ "guid": {
+ "type": "string",
+ "description": "Stash-internal unique product identifier (UUID)"
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product name",
+ "title": "Localizable Text"
+ },
+ "productId": {
+ "type": "string",
+ "description": "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"
+ },
+ "description": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product description. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "images": {
+ "type": "object",
+ "properties": {
+ "mainImage": {
+ "type": "string",
+ "description": "Primary product image URL. Always non-empty for published products"
+ },
+ "backgroundImage": {
+ "type": "string",
+ "description": "Background image URL"
+ }
+ },
+ "description": "Product images",
+ "title": "Images"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_STATUS_UNSPECIFIED",
+ "PRODUCT_STATUS_PUBLISHED"
+ ],
+ "default": "PRODUCT_STATUS_PUBLISHED",
+ "description": "Product status. Always PRODUCT_STATUS_PUBLISHED for this API",
+ "example": "PRODUCT_STATUS_PUBLISHED"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was created (RFC 3339)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was last updated (RFC 3339)"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Human-readable display name. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "prices": {
+ "type": "array",
+ "example": [
+ {
+ "currency": "USD",
+ "platform": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "region": "US",
+ "cents": 499,
+ "updatedAt": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"
+ },
+ "platform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "description": "Platform (e.g., UNIVERSAL)",
+ "example": "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ },
+ "region": {
+ "type": "string",
+ "description": "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"
+ },
+ "cents": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Price in smallest currency unit (e.g., 499 for $4.99)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when this price was last updated (RFC 3339)"
+ }
+ },
+ "description": "A price entry for a product",
+ "title": "Price"
+ },
+ "description": "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
+ },
+ "items": {
+ "type": "array",
+ "example": [
+ {
+ "image": "https://example.com/coin.png",
+ "quantity": "100",
+ "displayName": {
+ "defaultText": "Gold Coin"
+ }
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "description": "URL to the item image. Always non-empty for published products"
+ },
+ "quantity": {
+ "type": "string",
+ "description": "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Optional display name for the item (max 50 characters)",
+ "title": "Localizable Text"
+ }
+ },
+ "description": "An item contained in a product",
+ "title": "Item"
+ },
+ "description": "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
+ }
+ },
+ "description": "The requested product",
+ "title": "Product"
+ }
+ },
+ "description": "Response containing a single published product",
+ "title": "Get Product Response",
+ "required": [
+ "product"
+ ]
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "message": {
+ "type": "string"
+ },
+ "details": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "@type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": {}
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "shopId",
+ "description": "Shop identifier from Stash Studio",
+ "in": "path",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "name": "guid",
+ "description": "Stash-internal unique product identifier (UUID)",
+ "in": "path",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "tags": [
+ "ManagedCatalog"
+ ],
+ "security": [
+ {
+ "hmac": []
+ }
+ ]
+ }
+ }
+ },
+ "definitions": {
+ "protobufAny": {
+ "type": "object",
+ "properties": {
+ "@type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": {}
+ },
+ "rpcStatus": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "message": {
+ "type": "string"
+ },
+ "details": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "@type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": {}
+ }
+ }
+ }
+ },
+ "v1GetProductResponse": {
+ "type": "object",
+ "properties": {
+ "product": {
+ "type": "object",
+ "properties": {
+ "guid": {
+ "type": "string",
+ "description": "Stash-internal unique product identifier (UUID)"
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product name",
+ "title": "Localizable Text"
+ },
+ "productId": {
+ "type": "string",
+ "description": "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"
+ },
+ "description": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product description. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "images": {
+ "type": "object",
+ "properties": {
+ "mainImage": {
+ "type": "string",
+ "description": "Primary product image URL. Always non-empty for published products"
+ },
+ "backgroundImage": {
+ "type": "string",
+ "description": "Background image URL"
+ }
+ },
+ "description": "Product images",
+ "title": "Images"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_STATUS_UNSPECIFIED",
+ "PRODUCT_STATUS_PUBLISHED"
+ ],
+ "default": "PRODUCT_STATUS_PUBLISHED",
+ "description": "Product status. Always PRODUCT_STATUS_PUBLISHED for this API",
+ "example": "PRODUCT_STATUS_PUBLISHED"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was created (RFC 3339)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was last updated (RFC 3339)"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Human-readable display name. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "prices": {
+ "type": "array",
+ "example": [
+ {
+ "currency": "USD",
+ "platform": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "region": "US",
+ "cents": 499,
+ "updatedAt": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"
+ },
+ "platform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "description": "Platform (e.g., UNIVERSAL)",
+ "example": "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ },
+ "region": {
+ "type": "string",
+ "description": "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"
+ },
+ "cents": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Price in smallest currency unit (e.g., 499 for $4.99)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when this price was last updated (RFC 3339)"
+ }
+ },
+ "description": "A price entry for a product",
+ "title": "Price"
+ },
+ "description": "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
+ },
+ "items": {
+ "type": "array",
+ "example": [
+ {
+ "image": "https://example.com/coin.png",
+ "quantity": "100",
+ "displayName": {
+ "defaultText": "Gold Coin"
+ }
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "description": "URL to the item image. Always non-empty for published products"
+ },
+ "quantity": {
+ "type": "string",
+ "description": "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Optional display name for the item (max 50 characters)",
+ "title": "Localizable Text"
+ }
+ },
+ "description": "An item contained in a product",
+ "title": "Item"
+ },
+ "description": "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
+ }
+ },
+ "description": "The requested product",
+ "title": "Product"
+ }
+ },
+ "description": "Response containing a single published product",
+ "title": "Get Product Response",
+ "required": [
+ "product"
+ ]
+ },
+ "v1Images": {
+ "type": "object",
+ "properties": {
+ "mainImage": {
+ "type": "string",
+ "description": "Primary product image URL. Always non-empty for published products"
+ },
+ "backgroundImage": {
+ "type": "string",
+ "description": "Background image URL"
+ }
+ },
+ "description": "Product images",
+ "title": "Images"
+ },
+ "v1Item": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "description": "URL to the item image. Always non-empty for published products"
+ },
+ "quantity": {
+ "type": "string",
+ "description": "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Optional display name for the item (max 50 characters)",
+ "title": "Localizable Text"
+ }
+ },
+ "description": "An item contained in a product",
+ "title": "Item"
+ },
+ "v1ListProductsResponse": {
+ "type": "object",
+ "properties": {
+ "products": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "guid": {
+ "type": "string",
+ "description": "Stash-internal unique product identifier (UUID)"
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product name",
+ "title": "Localizable Text"
+ },
+ "productId": {
+ "type": "string",
+ "description": "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"
+ },
+ "description": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product description. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "images": {
+ "type": "object",
+ "properties": {
+ "mainImage": {
+ "type": "string",
+ "description": "Primary product image URL. Always non-empty for published products"
+ },
+ "backgroundImage": {
+ "type": "string",
+ "description": "Background image URL"
+ }
+ },
+ "description": "Product images",
+ "title": "Images"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_STATUS_UNSPECIFIED",
+ "PRODUCT_STATUS_PUBLISHED"
+ ],
+ "default": "PRODUCT_STATUS_PUBLISHED",
+ "description": "Product status. Always PRODUCT_STATUS_PUBLISHED for this API",
+ "example": "PRODUCT_STATUS_PUBLISHED"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was created (RFC 3339)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was last updated (RFC 3339)"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Human-readable display name. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "prices": {
+ "type": "array",
+ "example": [
+ {
+ "currency": "USD",
+ "platform": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "region": "US",
+ "cents": 499,
+ "updatedAt": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"
+ },
+ "platform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "description": "Platform (e.g., UNIVERSAL)",
+ "example": "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ },
+ "region": {
+ "type": "string",
+ "description": "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"
+ },
+ "cents": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Price in smallest currency unit (e.g., 499 for $4.99)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when this price was last updated (RFC 3339)"
+ }
+ },
+ "description": "A price entry for a product",
+ "title": "Price"
+ },
+ "description": "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
+ },
+ "items": {
+ "type": "array",
+ "example": [
+ {
+ "image": "https://example.com/coin.png",
+ "quantity": "100",
+ "displayName": {
+ "defaultText": "Gold Coin"
+ }
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "description": "URL to the item image. Always non-empty for published products"
+ },
+ "quantity": {
+ "type": "string",
+ "description": "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Optional display name for the item (max 50 characters)",
+ "title": "Localizable Text"
+ }
+ },
+ "description": "An item contained in a product",
+ "title": "Item"
+ },
+ "description": "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
+ }
+ },
+ "description": "A published product from the managed catalog",
+ "title": "Product"
+ },
+ "description": "List of published products"
+ },
+ "nextOffset": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Offset for the next page. Omitted if there are no more results."
+ }
+ },
+ "description": "Paginated list of published products",
+ "title": "List Products Response"
+ },
+ "v1LocalizableText": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Text content that can be localized for a specific language",
+ "title": "Localizable Text"
+ },
+ "v1Price": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"
+ },
+ "platform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "description": "Platform (e.g., UNIVERSAL)",
+ "example": "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ },
+ "region": {
+ "type": "string",
+ "description": "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"
+ },
+ "cents": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Price in smallest currency unit (e.g., 499 for $4.99)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when this price was last updated (RFC 3339)"
+ }
+ },
+ "description": "A price entry for a product",
+ "title": "Price"
+ },
+ "v1Product": {
+ "type": "object",
+ "properties": {
+ "guid": {
+ "type": "string",
+ "description": "Stash-internal unique product identifier (UUID)"
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product name",
+ "title": "Localizable Text"
+ },
+ "productId": {
+ "type": "string",
+ "description": "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"
+ },
+ "description": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Product description. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "images": {
+ "type": "object",
+ "properties": {
+ "mainImage": {
+ "type": "string",
+ "description": "Primary product image URL. Always non-empty for published products"
+ },
+ "backgroundImage": {
+ "type": "string",
+ "description": "Background image URL"
+ }
+ },
+ "description": "Product images",
+ "title": "Images"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_STATUS_UNSPECIFIED",
+ "PRODUCT_STATUS_PUBLISHED"
+ ],
+ "default": "PRODUCT_STATUS_PUBLISHED",
+ "description": "Product status. Always PRODUCT_STATUS_PUBLISHED for this API",
+ "example": "PRODUCT_STATUS_PUBLISHED"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was created (RFC 3339)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the product was last updated (RFC 3339)"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Human-readable display name. Always non-empty for published products",
+ "title": "Localizable Text"
+ },
+ "prices": {
+ "type": "array",
+ "example": [
+ {
+ "currency": "USD",
+ "platform": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "region": "US",
+ "cents": 499,
+ "updatedAt": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"
+ },
+ "platform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNIVERSAL",
+ "description": "Platform (e.g., UNIVERSAL)",
+ "example": "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ },
+ "region": {
+ "type": "string",
+ "description": "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"
+ },
+ "cents": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Price in smallest currency unit (e.g., 499 for $4.99)"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when this price was last updated (RFC 3339)"
+ }
+ },
+ "description": "A price entry for a product",
+ "title": "Price"
+ },
+ "description": "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
+ },
+ "items": {
+ "type": "array",
+ "example": [
+ {
+ "image": "https://example.com/coin.png",
+ "quantity": "100",
+ "displayName": {
+ "defaultText": "Gold Coin"
+ }
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "description": "URL to the item image. Always non-empty for published products"
+ },
+ "quantity": {
+ "type": "string",
+ "description": "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"
+ },
+ "displayName": {
+ "type": "object",
+ "properties": {
+ "defaultText": {
+ "type": "string",
+ "description": "Default text content, usually in English"
+ },
+ "localizationKey": {
+ "type": "string",
+ "description": "Key for client-side localization lookup"
+ },
+ "localizedText": {
+ "type": "string",
+ "description": "Server-resolved localized text"
+ }
+ },
+ "description": "Optional display name for the item (max 50 characters)",
+ "title": "Localizable Text"
+ }
+ },
+ "description": "An item contained in a product",
+ "title": "Item"
+ },
+ "description": "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
+ }
+ },
+ "description": "A published product from the managed catalog",
+ "title": "Product"
+ },
+ "v1ProductPricePlatform": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ ],
+ "default": "PRODUCT_PRICE_PLATFORM_UNSPECIFIED",
+ "description": "Platform for a price entry"
+ },
+ "v1ProductStatus": {
+ "type": "string",
+ "enum": [
+ "PRODUCT_STATUS_UNSPECIFIED",
+ "PRODUCT_STATUS_PUBLISHED"
+ ],
+ "default": "PRODUCT_STATUS_UNSPECIFIED",
+ "description": "Product publication status. This API only returns published products."
+ }
+ },
+ "securityDefinitions": {
+ "hmac": {
+ "type": "apiKey",
+ "description": "HMAC-SHA256 signature generated by signing the request body (empty string for GET requests) with the Ingress API key from Stash Studio. Base64-encoded.",
+ "name": "stash-hmac-signature",
+ "in": "header"
+ }
+ }
+}
\ No newline at end of file
diff --git a/gen.sh b/gen.sh
index 648e56f..41d5e75 100755
--- a/gen.sh
+++ b/gen.sh
@@ -39,4 +39,10 @@ npx --yes swagger-merger@1.5.4 -i ./docs/config/swagger-merger-config.json -o ./
echo "Building Redoc static HTML file..."
npx --yes @redocly/cli@2.6.0 build-docs ./docs/gen/swagger.v1.json -o ./docs/gen/redoc.v1.html
+echo "Merging Ingress Swagger files..."
+npx swagger-merger@1.5.4 -i ./docs/config/swagger-merger-ingress-config.json -o ./docs/gen/swagger.ingress.v1.json
+
+echo "Building Ingress Redoc static HTML file..."
+npx @redocly/cli@2.6.0 build-docs ./docs/gen/swagger.ingress.v1.json -o ./docs/gen/redoc.ingress.v1.html
+
echo "Code generation completed successfully!"
diff --git a/server/ingress/server.proto b/server/ingress/server.proto
new file mode 100644
index 0000000..66f1adf
--- /dev/null
+++ b/server/ingress/server.proto
@@ -0,0 +1,34 @@
+syntax = "proto3";
+
+// buf:lint:ignore PACKAGE_VERSION_SUFFIX
+package server.ingress;
+
+import "protoc-gen-openapiv2/options/annotations.proto";
+
+option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress";
+option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
+ info: {
+ title: "Stash Managed Catalog API"
+ description: "Read-only API for querying the Stash managed product catalog. Designed for server-to-server communication between partner backends and Stash services."
+ version: "1.0"
+ contact: {
+ name: "API Support"
+ url: "https://docs.stash.gg/"
+ email: "help@stash.gg"
+ }
+ }
+ schemes: HTTPS
+ consumes: "application/json"
+ produces: "application/json"
+ security_definitions: {
+ security: {
+ key: "hmac"
+ value: {
+ name: "stash-hmac-signature"
+ in: IN_HEADER
+ type: TYPE_API_KEY
+ description: "HMAC-SHA256 signature generated by signing the request body (empty string for GET requests) with the Ingress API key from Stash Studio. Base64-encoded."
+ }
+ }
+ }
+};
diff --git a/server/ingress/studio/v1/enums.proto b/server/ingress/studio/v1/enums.proto
new file mode 100644
index 0000000..a2c1f37
--- /dev/null
+++ b/server/ingress/studio/v1/enums.proto
@@ -0,0 +1,21 @@
+syntax = "proto3";
+
+package server.ingress.studio.v1;
+
+import "protoc-gen-openapiv2/options/annotations.proto";
+
+option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress/studio/v1";
+
+enum ProductStatus {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_enum) = {description: "Product publication status. This API only returns published products."};
+ reserved 1;
+ reserved "PRODUCT_STATUS_DRAFT";
+ PRODUCT_STATUS_UNSPECIFIED = 0;
+ PRODUCT_STATUS_PUBLISHED = 2;
+}
+
+enum ProductPricePlatform {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_enum) = {description: "Platform for a price entry"};
+ PRODUCT_PRICE_PLATFORM_UNSPECIFIED = 0;
+ PRODUCT_PRICE_PLATFORM_UNIVERSAL = 1;
+}
diff --git a/server/ingress/studio/v1/service.proto b/server/ingress/studio/v1/service.proto
new file mode 100644
index 0000000..c41d011
--- /dev/null
+++ b/server/ingress/studio/v1/service.proto
@@ -0,0 +1,220 @@
+syntax = "proto3";
+
+package server.ingress.studio.v1;
+
+import "google/api/annotations.proto";
+import "google/api/field_behavior.proto";
+import "google/protobuf/timestamp.proto";
+import "protoc-gen-openapiv2/options/annotations.proto";
+import "server/ingress/studio/v1/enums.proto";
+import "server/ingress/studio/v1/types.proto";
+import "validate/validate.proto";
+
+option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress/studio/v1";
+
+service ManagedCatalogService {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_tag) = {
+ name: "ManagedCatalog"
+ description: "Read-only endpoints for querying the managed product catalog"
+ };
+ rpc ListProducts(ListProductsRequest) returns (ListProductsResponse) {
+ option (google.api.http) = {get: "/api/v1/studio/{shop_id}/products"};
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
+ summary: "List published products"
+ description: "Retrieves a paginated list of published products for a shop. Results are ordered by creation date (most recent first). Only published products are returned; drafts and deleted products are excluded."
+ security: [
+ {
+ security_requirement: {key: "hmac"}
+ }
+ ]
+ };
+ }
+ rpc GetProduct(GetProductRequest) returns (GetProductResponse) {
+ option (google.api.http) = {get: "/api/v1/studio/{shop_id}/products/{guid}"};
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
+ summary: "Get a single published product"
+ description: "Retrieves a single published product by its GUID. Returns NOT_FOUND if the product does not exist or is not published."
+ security: [
+ {
+ security_requirement: {key: "hmac"}
+ }
+ ]
+ };
+ }
+}
+
+message Product {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Product"
+ description: "A published product from the managed catalog"
+ }
+ };
+ string guid = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Stash-internal unique product identifier (UUID)"}];
+ LocalizableText name = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Product name"}];
+ optional string product_id = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"}];
+ optional LocalizableText description = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Product description. Always non-empty for published products"}];
+ optional Images images = 5 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Product images"}];
+ ProductStatus status = 6 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "Product status. Always PRODUCT_STATUS_PUBLISHED for this API"
+ default: "PRODUCT_STATUS_PUBLISHED"
+ example: "\"PRODUCT_STATUS_PUBLISHED\""
+ }];
+ google.protobuf.Timestamp created_at = 7 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Timestamp when the product was created (RFC 3339)"}];
+ google.protobuf.Timestamp updated_at = 8 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Timestamp when the product was last updated (RFC 3339)"}];
+ optional LocalizableText display_name = 9 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Human-readable display name. Always non-empty for published products"}];
+ repeated Price prices = 10 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
+ example: "[{\"currency\": \"USD\", \"platform\": \"PRODUCT_PRICE_PLATFORM_UNIVERSAL\", \"region\": \"US\", \"cents\": 499, \"updatedAt\": \"2026-01-15T10:30:00Z\"}]"
+ }];
+ repeated Item items = 11 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
+ example: "[{\"image\": \"https://example.com/coin.png\", \"quantity\": \"100\", \"displayName\": {\"defaultText\": \"Gold Coin\"}}]"
+ }];
+}
+
+message Images {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Images"
+ description: "Product images"
+ }
+ };
+ string main_image = 1 [
+ (validate.rules).string = {
+ uri: true
+ ignore_empty: true
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Primary product image URL. Always non-empty for published products"}
+ ];
+ optional string background_image = 2 [
+ (validate.rules).string = {
+ uri: true
+ ignore_empty: true
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Background image URL"}
+ ];
+}
+
+message Price {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Price"
+ description: "A price entry for a product"
+ }
+ };
+ string currency = 1 [
+ (validate.rules).string = {
+ min_len: 3
+ max_len: 3
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"}
+ ];
+ ProductPricePlatform platform = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "Platform (e.g., UNIVERSAL)"
+ default: "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
+ example: "\"PRODUCT_PRICE_PLATFORM_UNIVERSAL\""
+ }];
+ string region = 3 [
+ (validate.rules).string = {
+ min_len: 2
+ max_len: 3
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"}
+ ];
+ uint32 cents = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Price in smallest currency unit (e.g., 499 for $4.99)"}];
+ google.protobuf.Timestamp updated_at = 5 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Timestamp when this price was last updated (RFC 3339)"}];
+}
+
+message Item {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Item"
+ description: "An item contained in a product"
+ }
+ };
+ string image = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "URL to the item image. Always non-empty for published products"}];
+ string quantity = 2 [
+ (validate.rules).string = {
+ min_len: 1
+ pattern: "^[0-9]+$"
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"}
+ ];
+ optional LocalizableText display_name = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Optional display name for the item (max 50 characters)"}];
+}
+
+message ListProductsRequest {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "List Products Request"
+ description: "Request to list published products for a shop"
+ required: ["shop_id"]
+ }
+ };
+ string shop_id = 1 [
+ (google.api.field_behavior) = REQUIRED,
+ (validate.rules).string.uuid = true,
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Shop identifier from Stash Studio"}
+ ];
+ optional uint32 limit = 2 [
+ (validate.rules).uint32.lte = 100,
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "Maximum results per page. Defaults to 50. Maximum allowed value is 100."
+ minimum: 1
+ maximum: 100
+ }
+ ];
+ optional uint32 offset = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "Pagination offset. Use next_offset from the previous response to fetch the next page. Defaults to 0."
+ minimum: 0
+ }];
+}
+
+message ListProductsResponse {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "List Products Response"
+ description: "Paginated list of published products"
+ }
+ };
+ repeated Product products = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "List of published products"}];
+ optional uint32 next_offset = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Offset for the next page. Omitted if there are no more results."}];
+}
+
+message GetProductRequest {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Get Product Request"
+ description: "Request to get a single published product"
+ required: [
+ "shop_id",
+ "guid"
+ ]
+ }
+ };
+ string shop_id = 1 [
+ (google.api.field_behavior) = REQUIRED,
+ (validate.rules).string.uuid = true,
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Shop identifier from Stash Studio"}
+ ];
+ string guid = 2 [
+ (google.api.field_behavior) = REQUIRED,
+ (validate.rules).string.uuid = true,
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Stash-internal unique product identifier (UUID)"}
+ ];
+}
+
+message GetProductResponse {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Get Product Response"
+ description: "Response containing a single published product"
+ required: ["product"]
+ }
+ };
+ Product product = 1 [
+ (google.api.field_behavior) = REQUIRED,
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "The requested product"}
+ ];
+}
diff --git a/server/ingress/studio/v1/types.proto b/server/ingress/studio/v1/types.proto
new file mode 100644
index 0000000..0a804fd
--- /dev/null
+++ b/server/ingress/studio/v1/types.proto
@@ -0,0 +1,34 @@
+syntax = "proto3";
+
+package server.ingress.studio.v1;
+
+import "protoc-gen-openapiv2/options/annotations.proto";
+import "validate/validate.proto";
+
+option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress/studio/v1";
+
+message LocalizableText {
+ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
+ json_schema: {
+ title: "Localizable Text"
+ description: "Text content that can be localized for a specific language"
+ }
+ };
+ string default_text = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Default text content, usually in English"}];
+ oneof localization {
+ string localization_key = 2 [
+ (validate.rules).string = {
+ min_len: 1
+ ignore_empty: true
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Key for client-side localization lookup"}
+ ];
+ string localized_text = 3 [
+ (validate.rules).string = {
+ min_len: 1
+ ignore_empty: true
+ },
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Server-resolved localized text"}
+ ];
+ }
+}