{
  "openapi": "3.1.0",
  "info": {
    "title": "Yoto Limiter API",
    "version": "1",
    "summary": "Read and change your Yoto players' limits from other apps, with an API key.",
    "description": "Everything here acts for the account that made the key, on the players that account owns or was invited to, and needs Plus. Make a key in the app: Settings, API keys. Each key has scopes: `read` (status, live status, history), `control` (pause, hold, add or take away time, as a member may) and `limits` (limits, card rules, resetting the day, the time zone, as only an owner may). A route needs its scope **and** the account's role on the player. Errors are plain text meant to be shown to people. See https://yoto-limiter.com/developers for a guide.",
    "contact": { "email": "support@yoto-limiter.com", "url": "https://yoto-limiter.com/developers" }
  },
  "servers": [{ "url": "https://yoto-limiter.com" }],
  "security": [{ "apiKey": [] }],
  "tags": [
    { "name": "read", "description": "Needs the `read` scope." },
    { "name": "control", "description": "Needs the `control` scope; a member or an owner of the player." },
    { "name": "limits", "description": "Needs the `limits` scope; an owner of the player." }
  ],
  "paths": {
    "/api/status": {
      "get": {
        "operationId": "status",
        "tags": ["read"],
        "summary": "Every player the account can see, now",
        "description": "Sends an `ETag`; ask again with `If-None-Match` and get an empty `304` while nothing changed. To follow changes, prefer `/api/events` to asking often.",
        "security": [{ "apiKey": ["read"] }],
        "responses": {
          "200": {
            "description": "The players.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Status" } } }
          },
          "304": { "description": "Nothing changed since the `ETag` sent in `If-None-Match`." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/events": {
      "get": {
        "operationId": "events",
        "tags": ["read"],
        "summary": "Live status, as server-sent events",
        "description": "A connection your app opens and keeps open, so it works from a home network with nothing reachable from outside. Sends a `status` event at once and whenever a player changes (at least every 60 s, every 15 s while one plays), each with the same data as `/api/status`; a comment every 25 s keeps it open. It ends soon after the key is deleted or the account leaves Plus: reconnect after a pause, not at once. At most 2 open per key.",
        "security": [{ "apiKey": ["read"] }],
        "responses": {
          "200": {
            "description": "`event: status` lines, each `data:` a Status.",
            "content": { "text/event-stream": { "schema": { "type": "string" }, "example": "event: status\ndata: {\"players\":[…]}\n\n" } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "description": "This key has 2 live streams open already.", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/api/history": {
      "get": {
        "operationId": "history",
        "tags": ["read"],
        "summary": "Minutes played each day",
        "security": [{ "apiKey": ["read"] }],
        "parameters": [
          { "$ref": "#/components/parameters/player" },
          { "name": "days", "in": "query", "description": "How many days back, today included.", "schema": { "type": "integer", "minimum": 1, "maximum": 90, "default": 14 } }
        ],
        "responses": {
          "200": {
            "description": "Oldest first.",
            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Day" } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" }
        }
      }
    },
    "/api/pause": {
      "post": {
        "operationId": "pause",
        "tags": ["control"],
        "summary": "Pause the player now",
        "description": "Once, whatever the limits say: the player can be played again straight away if its limits allow. To keep it paused, use `/api/hold`.",
        "security": [{ "apiKey": ["control"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "responses": {
          "202": { "description": "Sent to the player." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" }
        }
      }
    },
    "/api/hold": {
      "post": {
        "operationId": "hold",
        "tags": ["control"],
        "summary": "Keep the player paused for a while",
        "description": "Pauses now, and again whenever it plays, whatever its limits and hours, until the time is up. A hold already on is replaced. `held_until` in the status says when it ends.",
        "security": [{ "apiKey": ["control"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["minutes"],
                "properties": { "minutes": { "type": "integer", "minimum": 1, "maximum": 1440 } }
              },
              "example": { "minutes": 30 }
            }
          }
        },
        "responses": {
          "204": { "description": "Held." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" },
          "422": { "$ref": "#/components/responses/Unreadable" }
        }
      },
      "delete": {
        "operationId": "release",
        "tags": ["control"],
        "summary": "End the hold",
        "description": "The player may play again within its limits and hours.",
        "security": [{ "apiKey": ["control"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "responses": {
          "204": { "description": "No hold now." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" }
        }
      }
    },
    "/api/extend": {
      "post": {
        "operationId": "extend",
        "tags": ["control"],
        "summary": "Add or take away time today",
        "security": [{ "apiKey": ["control"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["minutes"],
                "properties": { "minutes": { "type": "integer", "minimum": -120, "maximum": 120, "not": { "const": 0 }, "description": "Negative takes time away." } }
              },
              "example": { "minutes": 10 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Today's time, changed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Budget" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "description": "The key or the account's role does not allow it, or that would go past the most time a day may be extended by (`max_extension_minutes`).", "content": { "text/plain": { "schema": { "type": "string" } } } },
          "404": { "$ref": "#/components/responses/UnknownPlayer" },
          "422": { "$ref": "#/components/responses/Unreadable" }
        }
      }
    },
    "/api/reset": {
      "post": {
        "operationId": "reset",
        "tags": ["limits"],
        "summary": "Start today again",
        "description": "Clears today's play and any time added or taken away.",
        "security": [{ "apiKey": ["limits"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "responses": {
          "200": {
            "description": "Today's time, cleared.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Budget" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" }
        }
      }
    },
    "/api/config": {
      "put": {
        "operationId": "setLimits",
        "tags": ["limits"],
        "summary": "Change the daily limit and allowed hours",
        "description": "A field left out is unchanged.",
        "security": [{ "apiKey": ["limits"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/Limits" },
              "example": { "daily_minutes": 45 }
            }
          }
        },
        "responses": {
          "204": { "description": "Changed." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" },
          "422": { "$ref": "#/components/responses/Unreadable" }
        }
      }
    },
    "/api/card": {
      "put": {
        "operationId": "setCard",
        "tags": ["limits"],
        "summary": "Set how a card counts, and its name",
        "description": "A rule with nothing in it clears the card's rule.",
        "security": [{ "apiKey": ["limits"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["card"],
                "properties": {
                  "card": { "type": "string", "description": "The card's ID, as `card.id` in the status gives it." },
                  "exempt": { "type": "boolean", "description": "Never counted or paused for time." },
                  "minutes": { "type": "integer", "minimum": 0, "maximum": 1440, "description": "The card's own daily limit." },
                  "name": { "type": "string", "description": "A name of your own for it." }
                }
              },
              "example": { "card": "c1", "name": "Bedtime story" }
            }
          }
        },
        "responses": {
          "204": { "description": "Changed." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" },
          "422": { "$ref": "#/components/responses/Unreadable" }
        }
      }
    },
    "/api/time-zone": {
      "put": {
        "operationId": "setTimeZone",
        "tags": ["limits"],
        "summary": "Set the household's time zone",
        "description": "Used only while the Yoto player has not said its own, which always wins.",
        "security": [{ "apiKey": ["limits"] }],
        "parameters": [{ "$ref": "#/components/parameters/player" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["time_zone"],
                "properties": { "time_zone": { "type": "string", "description": "An IANA name." } }
              },
              "example": { "time_zone": "Europe/London" }
            }
          }
        },
        "responses": {
          "204": { "description": "Changed." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NeedsPlus" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/UnknownPlayer" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer yl_…`, a key made in the app (Settings, API keys). Shown once when made; keep it like a password."
      }
    },
    "parameters": {
      "player": {
        "name": "player",
        "in": "query",
        "required": true,
        "description": "The player's `id`, from `/api/status`.",
        "schema": { "type": "integer" }
      }
    },
    "responses": {
      "BadRequest": { "description": "Something in the request does not make sense; the text says what.", "content": { "text/plain": { "schema": { "type": "string" } } } },
      "Unauthorized": { "description": "No key, or not one that works (`unknown API key`).", "content": { "text/plain": { "schema": { "type": "string" } } } },
      "NeedsPlus": { "description": "The account is not on Plus, or what was asked is part of Plus.", "content": { "text/plain": { "schema": { "type": "string" } } } },
      "Forbidden": { "description": "The key's scopes (`this API key is not for that: it needs the … scope`) or the account's role (`only an owner can do that`) do not allow it.", "content": { "text/plain": { "schema": { "type": "string" } } } },
      "UnknownPlayer": { "description": "No such player, or not one the account is on: the two look the same.", "content": { "text/plain": { "schema": { "type": "string" } } } },
      "Unreadable": { "description": "The body is not JSON of the right shape, or a number is out of range.", "content": { "text/plain": { "schema": { "type": "string" } } } }
    },
    "schemas": {
      "Status": {
        "type": "object",
        "required": ["players"],
        "properties": { "players": { "type": "array", "items": { "$ref": "#/components/schemas/Player" } } }
      },
      "Player": {
        "type": "object",
        "description": "One player. The main fields are here; there are more, and new ones may come, so ignore what you do not know.",
        "required": ["id", "name", "connected", "locked", "budget", "daily_limit", "daily_minutes", "time_zone", "today"],
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "role": { "enum": ["owner", "member"], "description": "The account's role on it." },
          "connected": { "type": "boolean", "description": "Connected to Yoto's cloud now. A player that is not cannot be paused." },
          "locked": { "type": "boolean", "description": "Outside the allowed hours now." },
          "held_until": { "type": ["string", "null"], "format": "date-time", "description": "While a hold is on: when it ends." },
          "daily_limit": { "type": "boolean", "description": "Whether the daily limit is on." },
          "daily_minutes": { "type": "integer", "description": "Today's limit." },
          "week_minutes": { "type": "array", "items": { "type": "integer" }, "description": "Each weekday's limit, Monday first." },
          "max_extension_minutes": { "type": "integer" },
          "allowed_from": { "type": ["string", "null"], "description": "Today's first stretch of allowed hours, \"HH:MM\" on the household's clock." },
          "allowed_until": { "type": ["string", "null"] },
          "time_zone": { "type": "string", "description": "The household's IANA time zone, which its day and hours follow." },
          "today": { "type": "string", "format": "date", "description": "The household's date." },
          "resets_at": { "type": ["string", "null"], "format": "date-time", "description": "The household's next midnight." },
          "budget": { "$ref": "#/components/schemas/Budget" },
          "card": {
            "type": ["object", "null"],
            "description": "The card in the player now.",
            "properties": { "id": { "type": "string" }, "name": { "type": ["string", "null"], "description": "Your name for it, else Yoto's title." } }
          },
          "today_by_card": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Seconds played today per card." }
        }
      },
      "Budget": {
        "type": "object",
        "description": "Today's time.",
        "additionalProperties": true,
        "properties": {
          "used_secs": { "type": "integer" },
          "limited": { "type": "boolean", "description": "False while the daily limit is off: then the limit and remaining seconds mean nothing." },
          "limit_secs": { "type": "integer", "description": "Today's limit with any time added or taken away." },
          "remaining_secs": { "type": "integer" },
          "extension_secs": { "type": "integer", "description": "Time added (or, negative, taken away) today." },
          "playing": { "type": "boolean" },
          "exhausted": { "type": "boolean", "description": "Today's time is used up." },
          "blocked": { "type": "boolean", "description": "The card in the player is over its own limit." },
          "card_id": { "type": ["string", "null"] },
          "total_played_secs": { "type": "integer", "description": "Everything played today, counted or not." }
        }
      },
      "Day": {
        "type": "object",
        "properties": {
          "day": { "type": "string", "format": "date" },
          "total_secs": { "type": "integer" },
          "by_card": { "type": "object", "additionalProperties": { "type": "integer" } }
        }
      },
      "Span": {
        "type": "object",
        "required": ["from", "until"],
        "properties": {
          "from": { "type": "string", "pattern": "^\\d{2}:\\d{2}$" },
          "until": { "type": "string", "pattern": "^\\d{2}:\\d{2}$", "description": "Earlier than `from` runs past midnight." }
        }
      },
      "Hours": {
        "type": "object",
        "description": "One day's allowed hours; `{}` is all day.",
        "properties": {
          "allowed_from": { "type": "string" },
          "allowed_until": { "type": "string" },
          "allowed_more": { "type": "array", "items": { "$ref": "#/components/schemas/Span" }, "maxItems": 3 }
        }
      },
      "Limits": {
        "type": "object",
        "description": "Weekday limits, allowed hours and `finish_track: true` are part of Plus.",
        "properties": {
          "daily_limit": { "type": "boolean", "description": "Switch the daily limit on or off; its minutes are kept." },
          "daily_minutes": { "type": "integer", "minimum": 0, "maximum": 1440, "description": "Every day's limit." },
          "day_minutes": { "type": "object", "description": "Some weekdays' own limits, by `mon` … `sun`.", "additionalProperties": { "type": "integer", "minimum": 0, "maximum": 1440 } },
          "allowed_from": { "type": "string", "description": "\"HH:MM\". Send with `allowed_until`; both \"\" clear the hours." },
          "allowed_until": { "type": "string" },
          "allowed_more": { "type": "array", "items": { "$ref": "#/components/schemas/Span" }, "maxItems": 3, "description": "Further stretches beside the first." },
          "day_windows": { "type": "object", "description": "Weekdays' own hours, by `mon` … `sun`, replacing all there were.", "additionalProperties": { "$ref": "#/components/schemas/Hours" } },
          "finish_track": { "type": "boolean", "description": "Let the track playing when time is up finish first, for 15 minutes at most." }
        }
      }
    }
  }
}
