{
  "openapi": "3.0.3",
  "info": {
    "title": "Masky Developer API",
    "version": "1.2.0",
    "description": "Full Masky HTTP API: images, avatars, conversations, talking-head video, and Login with Masky (OAuth 2.0 SSO). Human docs at https://masky.ai/api/docs"
  },
  "servers": [
    {
      "url": "https://masky.ai/api"
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Either a Masky API key (mky_...) or a Firebase ID token."
      }
    },
    "schemas": {
      "Avatar": {
        "type": "object",
        "properties": {
          "avatarOwnerUserId": {
            "type": "string"
          },
          "avatarOwnerDisplayName": {
            "type": "string",
            "nullable": true
          },
          "avatarOwnerTwitchUsername": {
            "type": "string",
            "nullable": true
          },
          "avatarId": {
            "type": "string"
          },
          "displayName": {
            "type": "string",
            "nullable": true
          },
          "avatarImageUrl": {
            "type": "string",
            "nullable": true
          },
          "personalityPrompt": {
            "type": "string",
            "nullable": true
          },
          "humeVoiceId": {
            "type": "string",
            "nullable": true
          },
          "publiclyRenderable": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "integer",
            "nullable": true
          },
          "updatedAt": {
            "type": "integer",
            "nullable": true
          }
        }
      },
      "Turn": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "avatar"
            ]
          },
          "userText": {
            "type": "string",
            "nullable": true
          },
          "avatarText": {
            "type": "string",
            "nullable": true
          },
          "output": {
            "type": "string",
            "enum": [
              "audio",
              "video"
            ],
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true,
            "description": "pending | audio | video | error"
          },
          "audioUrl": {
            "type": "string",
            "nullable": true
          },
          "videoUrl": {
            "type": "string",
            "nullable": true
          },
          "shareSlug": {
            "type": "string",
            "nullable": true
          },
          "shareUrl": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "integer",
            "nullable": true
          }
        }
      }
    }
  },
  "paths": {
    "/api-keys": {
      "post": {
        "summary": "Create a new API key",
        "description": "Returns the raw mky_ token ONCE. Store it; the server keeps only a hash.",
        "x-masky-auth-mode": "firebase-only",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 80
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "token": {
                      "type": "string"
                    },
                    "createdAt": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Firebase sign-in required"
          }
        }
      },
      "get": {
        "summary": "List the caller's API keys (no raw tokens)",
        "x-masky-auth-mode": "firebase-only",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/api-keys/{id}": {
      "delete": {
        "summary": "Revoke an API key",
        "x-masky-auth-mode": "firebase-only",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/avatars": {
      "get": {
        "summary": "List the caller's avatars",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "avatars": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Avatar"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Create a new avatar",
        "description": "imageUrl, if supplied, is fetched and re-hosted on Masky's storage. Without a humeVoiceId the avatar is text-only; upload voice samples via masky.ai to enable audio/video.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "displayName"
                ],
                "properties": {
                  "displayName": {
                    "type": "string",
                    "maxLength": 80
                  },
                  "imageUrl": {
                    "type": "string",
                    "description": "Public URL; server fetches + stores."
                  },
                  "personalityPrompt": {
                    "type": "string",
                    "maxLength": 4000
                  },
                  "humeVoiceId": {
                    "type": "string"
                  },
                  "knowledgeBaseUrls": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 10
                  },
                  "publiclyRenderable": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "avatar": {
                      "$ref": "#/components/schemas/Avatar"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid imageUrl"
          }
        }
      }
    },
    "/avatars/public": {
      "get": {
        "summary": "List publicly renderable avatars across all users",
        "description": "No auth required. Returned avatars can be rendered by any caller; the owner pays the generation credits.",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/avatars/{avatarId}/public": {
      "post": {
        "summary": "Toggle publiclyRenderable on an avatar you own",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "avatarId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "publiclyRenderable"
                ],
                "properties": {
                  "publiclyRenderable": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not yours"
          }
        }
      }
    },
    "/voices": {
      "get": {
        "summary": "List voices available to the caller",
        "description": "Curated preset voices plus any custom voices already attached to avatars the caller owns. Use the returned id with /avatars/{id}/voice or pass it as voiceId on create_avatar.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "voices": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "source": {
                            "type": "string",
                            "enum": [
                              "preset",
                              "custom"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/avatars/{avatarId}/train": {
      "post": {
        "summary": "Build a Masky Gem for the avatar (personality + knowledge base)",
        "description": "Forwards the avatar's personalityPrompt + knowledgeBaseUrls to the Masky Gems microservice. Persists the returned gemRef on the avatar so future chat/turn calls reuse it instead of re-fetching URLs each turn. Gemini's app-product 'Gems' has no public API; this is our equivalent.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "avatarId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Trained or queued"
          },
          "404": {
            "description": "Not yours"
          },
          "502": {
            "description": "Microservice error"
          }
        }
      }
    },
    "/avatars/{avatarId}/voice": {
      "post": {
        "summary": "Attach a voice to an avatar you own",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "avatarId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "voiceId"
                ],
                "properties": {
                  "voiceId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not yours"
          }
        }
      }
    },
    "/conversations": {
      "post": {
        "summary": "Start a new conversation with an avatar",
        "description": "Returns conversationId, the shareUrl (interactive page), and a liveUrl with embedded viewerToken for OBS / web embeds.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "avatarOwnerUserId",
                  "avatarId"
                ],
                "properties": {
                  "avatarOwnerUserId": {
                    "type": "string"
                  },
                  "avatarId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "conversationId": {
                      "type": "string"
                    },
                    "shareSlug": {
                      "type": "string"
                    },
                    "shareUrl": {
                      "type": "string"
                    },
                    "viewerToken": {
                      "type": "string"
                    },
                    "liveUrl": {
                      "type": "string",
                      "description": "https://masky.ai/live/{slug}?token={viewerToken} \u2014 embed in OBS / web."
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Avatar not yours and not publiclyRenderable"
          }
        }
      }
    },
    "/conversations/{conversationId}/turn": {
      "post": {
        "summary": "Inject a user turn into a conversation",
        "description": "Asynchronous \u2014 returns a pending Turn id; subscribe to /liveTurns/{viewerToken}/turns or poll to watch status flip to audio then video.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "conversationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "userText"
                ],
                "properties": {
                  "userText": {
                    "type": "string",
                    "maxLength": 500
                  },
                  "output": {
                    "type": "string",
                    "enum": [
                      "audio",
                      "video"
                    ],
                    "default": "audio"
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "chat",
                      "speak"
                    ],
                    "default": "chat",
                    "description": "chat = Gemini answers using personality + history. speak = userText is spoken by the avatar (skips Gemini)."
                  },
                  "reinterpret": {
                    "type": "boolean",
                    "default": false,
                    "description": "Only used when mode=speak. true rewrites userText via Gemini in the avatar's voice before TTS."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted; turn is generating",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "turn": {
                      "$ref": "#/components/schemas/Turn"
                    },
                    "firestorePath": {
                      "type": "string"
                    },
                    "async": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "402": {
            "description": "Not enough credits"
          }
        }
      }
    },
    "/conversations/{conversationId}/visibility": {
      "post": {
        "summary": "Toggle conversation public/private",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "conversationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "isPublic"
                ],
                "properties": {
                  "isPublic": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/conversations/by-slug/{slug}": {
      "get": {
        "summary": "Fetch conversation metadata and full turn list",
        "description": "Public conversations are readable without auth. Private conversations require the owner's bearer token.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^c-\\d{2}-\\d{2}-[a-z0-9]{4}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "403": {
            "description": "Private and viewer is not the owner"
          },
          "404": {
            "description": "Unknown slug"
          }
        }
      }
    },
    "/avatars/{avatarId}/speak": {
      "post": {
        "summary": "One-shot avatar TTS / talking-head video",
        "description": "Use this when you need a single audio or video clip without a chat thread.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "avatarId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "text"
                ],
                "properties": {
                  "text": {
                    "type": "string",
                    "maxLength": 500
                  },
                  "textMode": {
                    "type": "string",
                    "enum": [
                      "literal",
                      "personality"
                    ],
                    "default": "literal"
                  },
                  "output": {
                    "type": "string",
                    "enum": [
                      "audio",
                      "video"
                    ],
                    "default": "audio"
                  },
                  "avatarOwnerUserId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reused or inline complete"
          },
          "202": {
            "description": "Job accepted; poll"
          }
        }
      }
    },
    "/avatars/speak/{generationId}": {
      "get": {
        "summary": "Get one-shot generation status + signed URLs",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "generationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/images/generate": {
      "post": {
        "summary": "Generate an image from text. Sync. Needs 'generate' scope for SSO tokens.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "prompt"
                ],
                "properties": {
                  "prompt": {
                    "type": "string"
                  },
                  "aspectRatio": {
                    "type": "string",
                    "enum": [
                      "1:1",
                      "16:9",
                      "9:16",
                      "4:3",
                      "3:4",
                      "3:2",
                      "2:3"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ imageUrl, aspectRatio, creditCost }"
          },
          "402": {
            "description": "Insufficient credits"
          },
          "403": {
            "description": "Token lacks 'generate' scope"
          }
        }
      }
    },
    "/images/edit": {
      "post": {
        "summary": "Edit/compose from 1-5 reference images. Needs 'generate' scope for SSO tokens.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "prompt"
                ],
                "properties": {
                  "prompt": {
                    "type": "string"
                  },
                  "imageUrl": {
                    "type": "string"
                  },
                  "images": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "aspectRatio": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ imageUrl, aspectRatio, creditCost }"
          },
          "402": {
            "description": "Insufficient credits"
          },
          "403": {
            "description": "Token lacks 'generate' scope"
          }
        }
      }
    },
    "/oauth/clients": {
      "post": {
        "summary": "Register a Login-with-Masky client. First-party mky_ key only.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "redirectDomains"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "redirectDomains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "scopes": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "profile",
                        "avatars:read",
                        "generate"
                      ]
                    }
                  },
                  "serviceAvatarId": {
                    "type": "string",
                    "description": "Optional: one of your avatar ids. Enables grant_type=client_credentials \u2014 the app acts as this avatar."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ clientId, clientSecret (shown once), name, redirectDomains, scopes }"
          }
        }
      },
      "get": {
        "summary": "List your registered SSO clients.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{ apps: [...] }"
          }
        }
      }
    },
    "/oauth/clients/{clientId}": {
      "delete": {
        "summary": "Delete an SSO client.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "clientId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "patch": {
        "summary": "Set or clear the client's service avatar (owner only). Enables grant_type=client_credentials: the app acts as this avatar. Send {\"serviceAvatarId\": \"<avatarId>\"} or {\"serviceAvatarId\": null}.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "serviceAvatarId"
                ],
                "properties": {
                  "serviceAvatarId": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "One of the owner's avatar ids, or null to disable client_credentials."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ ok, clientId, serviceAvatarId }"
          },
          "400": {
            "description": "serviceAvatarId must be one of your avatars"
          },
          "401": {
            "description": "Sign in required"
          },
          "404": {
            "description": "App not found"
          }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "summary": "Exchange an authorization code for a scoped mky_ access token, or use grant_type=client_credentials to act as the client's service avatar (no user, no consent). No auth (client_secret or PKCE in body).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type",
                  "client_id"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "authorization_code",
                      "client_credentials"
                    ]
                  },
                  "code": {
                    "type": "string"
                  },
                  "redirect_uri": {
                    "type": "string"
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string"
                  },
                  "code_verifier": {
                    "type": "string"
                  },
                  "scope": {
                    "type": "string",
                    "description": "client_credentials only: space-separated scopes to request (subset of the client's scopes; defaults to all)."
                  }
                },
                "description": "authorization_code requires code + redirect_uri (+ client_secret or code_verifier). client_credentials requires client_secret and a serviceAvatarId configured on the client; the token acts AS that avatar and bills the client owner. Tokens are long-lived \u2014 request once and store."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ access_token, token_type, scope, avatar }"
          },
          "400": {
            "description": "invalid_grant / unsupported_grant_type"
          },
          "401": {
            "description": "invalid_client"
          }
        }
      }
    },
    "/oauth/userinfo": {
      "get": {
        "summary": "Avatar identity for an SSO access token.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{ sub, name, picture, avatar_id, scope }"
          },
          "401": {
            "description": "invalid_token"
          }
        }
      }
    },
    "/oauth/grants": {
      "get": {
        "summary": "List apps a user has authorized (connected apps). First-party key.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{ grants: [...] }"
          }
        }
      }
    },
    "/oauth/grants/{tokenId}": {
      "delete": {
        "summary": "Revoke one issued SSO access token.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "tokenId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/videos/generate": {
      "post": {
        "summary": "Start a video render (avatar-aware). Async \u2014 poll GET /videos/{id}. Needs 'generate' scope for SSO tokens.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "prompt"
                ],
                "properties": {
                  "prompt": {
                    "type": "string"
                  },
                  "avatarIds": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "image": {
                    "type": "string"
                  },
                  "srcVideo": {
                    "type": "string"
                  },
                  "model": {
                    "type": "string",
                    "enum": [
                      "cinematic",
                      "motion",
                      "scene",
                      "ensemble"
                    ]
                  },
                  "resolution": {
                    "type": "string"
                  },
                  "aspectRatio": {
                    "type": "string"
                  },
                  "duration": {
                    "type": "integer"
                  },
                  "seed": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "{ generationId, status:'pending', model, referencedAvatars, poll }"
          },
          "400": {
            "description": "missing prompt / model needs an image"
          },
          "402": {
            "description": "Insufficient credits"
          },
          "403": {
            "description": "Token lacks 'generate' scope"
          }
        }
      }
    },
    "/videos/{generationId}": {
      "get": {
        "summary": "Poll a video job; returns status (pending|video|error) + videoUrl when ready.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "generationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{ status, videoUrl, model }"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    }
  }
}