{
  "openapi": "3.1.0",
  "info": {
    "title": "Zyplo Game API",
    "description": "Publish and manage HTML5 mini-games on a Zyplo creator account.\n\nThis spec covers the creator-facing surface: uploading a game and editing, publishing or deleting one you own. Routes the zyplo.io front end calls for itself (moderation reports, SEO, OAuth) are deliberately not described here.\n\nAuth is a Bearer token: an OAuth access token (zyplo_at_...) or a personal API key (zyplo_sk_...) from Creator Profile -> API & Connectors.\n\nGames must be a single self-contained HTML document. The full format rules, and every error code below, are at https://zyplo.io/en/integrate.",
    "version": "1.2.0"
  },
  "servers": [
    {
      "url": "https://zyplo.io"
    }
  ],
  "security": [
    {
      "ZyploBearerAuth": []
    }
  ],
  "paths": {
    "/api/upload-game": {
      "post": {
        "operationId": "uploadGame",
        "summary": "Upload an HTML5 game to the user's Zyplo account",
        "description": "Creates a new game. Upload as a draft (published: false) so the creator can review it before it reaches the feed.\n\nOne upload per account per 10 seconds; a faster call answers 400 with code rate_limited and the wait in `detail`.\n\nPass ?dry_run=true to validate without creating anything. The dry run runs the upload's own checks, reports every problem at once instead of stopping at the first, and writes nothing — no stored file, no game row, and no upload rate-limit stamp, so you can iterate freely. Authentication still applies: a bad token answers 401, not a validation report. Two gates are left out on purpose: the 10-second rate limit, which is about when you call rather than what you send, and the cover, because checking cover_url would mean fetching it. So `ok: true` means the game is valid, not that the upload is guaranteed to succeed.",
        "x-openai-isConsequential": true,
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UploadGameRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Game created, or — with ?dry_run=true — the validation report.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/UploadGameResponse"
                    },
                    {
                      "$ref": "#/components/schemas/DryRunResponse"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "parameters": [
          {
            "name": "dry_run",
            "in": "query",
            "required": false,
            "description": "Validate only, create nothing. Accepts `true`, `1`, or the bare flag; `false` and `0` upload for real, the same as omitting the parameter. Any other value answers 400 with code invalid_dry_run rather than guessing \u2014 the wrong guess would upload the game.",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "1",
                "false",
                "0"
              ]
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Language for `fix` text and `docs_url` in error responses. Falls back to Accept-Language, then English.",
            "schema": {
              "type": "string",
              "enum": [
                "en",
                "es"
              ]
            }
          }
        ]
      }
    },
    "/api/my-game": {
      "post": {
        "operationId": "myGame",
        "summary": "Edit, publish, unpublish or delete a game the user owns",
        "description": "One endpoint, three actions, chosen by the `action` field:\n\n- `update` - change title, description, tags or cover. Only the fields you send are written; omit the rest.\n- `set_visibility` - publish (published: true) or hide (published: false).\n- `delete` - remove the game and its stored HTML permanently.\n\nA game_id that does not belong to the authenticated account answers 401 with code game_not_found. A game held for moderation answers 400 with code game_under_review.",
        "x-openai-isConsequential": true,
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MyGameRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Action applied",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/MutateGameResponse"
                    },
                    {
                      "$ref": "#/components/schemas/DeleteGameResponse"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ZyploBearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "OAuth access token (zyplo_at_...) or personal API key (zyplo_sk_...)."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request needs changing. See `code` and `fix`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Token missing, invalid, suspended, or the game is not this account's.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Only POST is accepted.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "ServerError": {
        "description": "A fault on Zyplo's side. Retry once, then report it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      }
    },
    "schemas": {
      "UploadGameRequest": {
        "type": "object",
        "required": [
          "html_content"
        ],
        "description": "A title and a description are required, but either the shared field or a localized one satisfies each.",
        "allOf": [
          {
            "anyOf": [
              {
                "required": [
                  "title"
                ]
              },
              {
                "required": [
                  "title_es"
                ]
              },
              {
                "required": [
                  "title_en"
                ]
              }
            ]
          },
          {
            "anyOf": [
              {
                "required": [
                  "description"
                ]
              },
              {
                "required": [
                  "description_es"
                ]
              },
              {
                "required": [
                  "description_en"
                ]
              }
            ]
          }
        ],
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 120,
            "description": "Used for both languages when the localized fields are absent. Keep under ~40 characters."
          },
          "title_es": {
            "type": "string",
            "maxLength": 120,
            "description": "Spanish variant. Overrides the shared field for Spanish viewers."
          },
          "title_en": {
            "type": "string",
            "maxLength": 120,
            "description": "English variant. Overrides the shared field for English viewers."
          },
          "description": {
            "type": "string",
            "maxLength": 500,
            "description": "Used for both languages when the localized fields are absent. Keep under ~100 characters."
          },
          "description_es": {
            "type": "string",
            "maxLength": 500,
            "description": "Spanish variant. Overrides the shared field for Spanish viewers."
          },
          "description_en": {
            "type": "string",
            "maxLength": 500,
            "description": "English variant. Overrides the shared field for English viewers."
          },
          "html_content": {
            "type": "string",
            "description": "The complete, self-contained HTML document. Rejected above 5 MB; keep it under 90,000 characters so it also survives ChatGPT's payload limit. External scripts, fetch, iframes, forms and relative asset paths are all rejected — see https://zyplo.io/en/integrate#formato.",
            "example": "<!doctype html><html>...</html>"
          },
          "tags": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "type": "string",
              "enum": [
                "arcade",
                "puzzle",
                "skill",
                "casual",
                "humor",
                "meme",
                "snake",
                "breakout",
                "tetris",
                "dodge",
                "memory",
                "logic",
                "reflex",
                "timing",
                "precision",
                "runner",
                "retro",
                "action",
                "platformer",
                "strategy"
              ]
            },
            "description": "Up to 10 tags, from this fixed list. Any other value answers 400 with code unsupported_tag and the offending tag in `detail`. Tags past the tenth are dropped silently.",
            "example": [
              "arcade",
              "reflex"
            ]
          },
          "cover_url": {
            "type": "string",
            "format": "uri",
            "description": "Public https URL to a JPG, PNG or WebP. Downloaded and re-hosted by Zyplo. Mutually exclusive with cover_data_url."
          },
          "cover_data_url": {
            "type": "string",
            "description": "data: URL of a JPG, PNG or WebP. Mutually exclusive with cover_url.",
            "example": "data:image/png;base64,iVBORw0KGgo..."
          },
          "published": {
            "type": "boolean",
            "default": false,
            "description": "false uploads a private draft (recommended). true puts it in the public feed immediately."
          }
        }
      },
      "UploadGameResponse": {
        "type": "object",
        "required": [
          "game_id",
          "game_url",
          "iframe_url",
          "title",
          "description",
          "title_es",
          "title_en",
          "description_es",
          "description_en",
          "tags",
          "published"
        ],
        "properties": {
          "game_id": {
            "type": "string",
            "format": "uuid"
          },
          "game_url": {
            "type": "string",
            "format": "uri",
            "description": "Public page to view and share the game."
          },
          "iframe_url": {
            "type": "string",
            "format": "uri",
            "description": "Direct URL of the stored HTML file."
          },
          "cover_url": {
            "type": "string",
            "format": "uri",
            "description": "Present when a cover was supplied or generated."
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "title_es": {
            "type": "string"
          },
          "title_en": {
            "type": "string"
          },
          "description_es": {
            "type": "string"
          },
          "description_en": {
            "type": "string"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Tags as stored, after normalization."
          },
          "published": {
            "type": "boolean"
          }
        }
      },
      "MyGameRequest": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/MyGameUpdate"
          },
          {
            "$ref": "#/components/schemas/MyGameSetVisibility"
          },
          {
            "$ref": "#/components/schemas/MyGameDelete"
          }
        ],
        "discriminator": {
          "propertyName": "action"
        }
      },
      "MyGameUpdate": {
        "type": "object",
        "required": [
          "action",
          "params"
        ],
        "description": "Change metadata. Send only the fields you want written.",
        "properties": {
          "action": {
            "type": "string",
            "const": "update"
          },
          "params": {
            "type": "object",
            "required": [
              "game_id"
            ],
            "properties": {
              "game_id": {
                "type": "string",
                "format": "uuid"
              },
              "title": {
                "type": "string",
                "maxLength": 120,
                "description": "Used for both languages when the localized fields are absent. Keep under ~40 characters."
              },
              "title_es": {
                "type": "string",
                "maxLength": 120,
                "description": "Spanish variant. Overrides the shared field for Spanish viewers."
              },
              "title_en": {
                "type": "string",
                "maxLength": 120,
                "description": "English variant. Overrides the shared field for English viewers."
              },
              "description": {
                "type": "string",
                "maxLength": 500,
                "description": "Used for both languages when the localized fields are absent. Keep under ~100 characters."
              },
              "description_es": {
                "type": "string",
                "maxLength": 500,
                "description": "Spanish variant. Overrides the shared field for Spanish viewers."
              },
              "description_en": {
                "type": "string",
                "maxLength": 500,
                "description": "English variant. Overrides the shared field for English viewers."
              },
              "tags": {
                "type": "array",
                "maxItems": 10,
                "items": {
                  "type": "string",
                  "enum": [
                    "arcade",
                    "puzzle",
                    "skill",
                    "casual",
                    "humor",
                    "meme",
                    "snake",
                    "breakout",
                    "tetris",
                    "dodge",
                    "memory",
                    "logic",
                    "reflex",
                    "timing",
                    "precision",
                    "runner",
                    "retro",
                    "action",
                    "platformer",
                    "strategy"
                  ]
                },
                "description": "Up to 10 tags, from this fixed list. Any other value answers 400 with code unsupported_tag and the offending tag in `detail`. Tags past the tenth are dropped silently.",
                "example": [
                  "arcade",
                  "reflex"
                ]
              },
              "cover_url": {
                "type": "string",
                "format": "uri",
                "description": "Public https URL to a JPG, PNG or WebP. Downloaded and re-hosted by Zyplo. Mutually exclusive with cover_data_url."
              },
              "cover_data_url": {
                "type": "string",
                "description": "data: URL of a JPG, PNG or WebP. Mutually exclusive with cover_url.",
                "example": "data:image/png;base64,iVBORw0KGgo..."
              },
              "published": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "MyGameSetVisibility": {
        "type": "object",
        "required": [
          "action",
          "params"
        ],
        "description": "Publish or hide a game without changing anything else.",
        "properties": {
          "action": {
            "type": "string",
            "const": "set_visibility"
          },
          "params": {
            "type": "object",
            "required": [
              "game_id",
              "published"
            ],
            "properties": {
              "game_id": {
                "type": "string",
                "format": "uuid"
              },
              "published": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "MyGameDelete": {
        "type": "object",
        "required": [
          "action",
          "params"
        ],
        "description": "Delete the game and its stored HTML. Not reversible.",
        "properties": {
          "action": {
            "type": "string",
            "const": "delete"
          },
          "params": {
            "type": "object",
            "required": [
              "game_id"
            ],
            "properties": {
              "game_id": {
                "type": "string",
                "format": "uuid"
              }
            }
          }
        }
      },
      "MutateGameResponse": {
        "type": "object",
        "required": [
          "game_id",
          "published"
        ],
        "description": "Returned by update and set_visibility.",
        "properties": {
          "game_id": {
            "type": "string",
            "format": "uuid"
          },
          "published": {
            "type": "boolean"
          },
          "cover_url": {
            "type": "string",
            "format": "uri",
            "description": "Present when the cover changed."
          }
        }
      },
      "DeleteGameResponse": {
        "type": "object",
        "required": [
          "game_id",
          "deleted"
        ],
        "properties": {
          "game_id": {
            "type": "string",
            "format": "uuid"
          },
          "deleted": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": [
          "error",
          "code",
          "fix",
          "docs_url"
        ],
        "description": "Every failure answers with this shape. `error` is unchanged from before machine-readable codes existed; the rest is what lets a caller act without guessing.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          },
          "code": {
            "type": "string",
            "description": "Stable machine-readable code, e.g. external_script, missing_field, plan_limit_reached, rate_limited. Branch on this, never on the message text. `internal_error` means the failure was not one Zyplo describes yet.",
            "example": "external_script"
          },
          "field": {
            "type": "string",
            "description": "The request field at fault, when the failure points at one.",
            "example": "html_content"
          },
          "detail": {
            "type": "string",
            "description": "The offending value, when naming it saves a guess — the external script URL, the seconds to wait, the plan limit that was hit.",
            "example": "https://cdn.example/phaser.js"
          },
          "fix": {
            "type": "string",
            "description": "What to change before retrying. Written to be actionable by an agent without further lookup.",
            "example": "Inline the library into a <script> block."
          },
          "docs_url": {
            "type": "string",
            "format": "uri",
            "description": "The section of https://zyplo.io/en/integrate that explains this code.",
            "example": "https://zyplo.io/en/integrate#error-external_script"
          }
        }
      },
      "DryRunResponse": {
        "type": "object",
        "required": [
          "dry_run",
          "ok",
          "issues",
          "normalized",
          "html_bytes",
          "quota"
        ],
        "description": "Answered with HTTP 200 whether or not the game is valid — the status reports that the check ran, `ok` reports the verdict.",
        "properties": {
          "dry_run": {
            "type": "boolean",
            "const": true
          },
          "ok": {
            "type": "boolean",
            "description": "true when `issues` is empty and a real upload would succeed."
          },
          "issues": {
            "type": "array",
            "description": "Every problem found, not just the first. Same object shape as an error response.",
            "items": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          },
          "normalized": {
            "type": "object",
            "description": "What the upload would store after trimming, language fallback and tag filtering.",
            "required": [
              "title",
              "description",
              "title_es",
              "title_en",
              "description_es",
              "description_en",
              "tags",
              "published"
            ],
            "properties": {
              "title": {
                "type": "string"
              },
              "description": {
                "type": "string"
              },
              "title_es": {
                "type": "string"
              },
              "title_en": {
                "type": "string"
              },
              "description_es": {
                "type": "string"
              },
              "description_en": {
                "type": "string"
              },
              "tags": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "published": {
                "type": "boolean"
              }
            }
          },
          "html_bytes": {
            "type": "integer",
            "description": "Size of html_content in bytes, as measured against the limit."
          },
          "quota": {
            "type": "object",
            "description": "Where the account stands. games_remaining 0 means a real upload would answer plan_limit_reached.",
            "required": [
              "plan_id",
              "games_used",
              "games_max",
              "games_remaining"
            ],
            "properties": {
              "plan_id": {
                "type": "string"
              },
              "games_used": {
                "type": "integer"
              },
              "games_max": {
                "type": "integer"
              },
              "games_remaining": {
                "type": "integer"
              }
            }
          }
        }
      }
    }
  }
}
