{
  "openapi": "3.0.0",
  "info": {
    "version": "v1.0",
    "title": "Gondola Portfolio API",
    "description": "## Overview\nThe Gondola Portfolio API lets a creator or company expose a token-scoped,\nread-only view of their Gondola profile data for use on personal portfolio\nwebsites, custom dashboards, and agent workflows.\n\nThis endpoint is designed for both developers and agents:\n- It returns a stable JSON envelope with clear pagination metadata.\n- It exposes account access tier information so callers can adapt to free\n  vs PRO tiers without guessing.\n- It returns normalized post and credit data that can be rendered directly\n  or transformed into custom site content.\n\n## Authentication\nRequests to this endpoint require a **portfolio token** in the `Authorization`\nheader using the Bearer scheme:\n\n```bash\ncurl --request GET \\\n  --url https://gondola.cc/api/v1/portfolio \\\n  --header 'Accept: application/json' \\\n  --header 'Authorization: Bearer <YOUR_PORTFOLIO_TOKEN>'\n```\n\nPortfolio tokens are created and rotated separately from this endpoint via\nGondola's authenticated app APIs:\n- `POST /api/token/portfolio` to create or rotate a token\n- `GET /api/token/portfolio` to inspect token metadata\n- `DELETE /api/token/portfolio` to revoke the token\n\n## Access Tiers\nThe same endpoint serves both free and PRO users. The token owner's current\nsubscription status is checked on every request.\n\nFree behavior:\n- Returns full profile stats\n- Returns only the 5 most recent portfolio posts\n- Ignores pagination requests beyond the first page\n- Rejects custom sorting with a 403 response\n- Rejects content filters with a 403 response\n\nPRO behavior:\n- Returns full profile stats\n- Returns a paginated portfolio feed\n- Honors `from` and `size` query parameters\n- Honors `sort` query parameter\n- Honors network, account, collaborator, and role filters\n\n## Intended Use\nCommon uses include:\n- Rendering a custom portfolio website\n- Creating a \"recent credited work\" section\n- Syncing Gondola work into a CMS\n- Letting an agent summarize a creator's recent work\n\n## Security Guidance\nPortfolio tokens are read-only and this endpoint is CORS-enabled for direct\nbrowser use. That means visitors can inspect the token if you embed it in a\npublic site. Use direct client-side calls only when that trade-off is\nacceptable, and expect Gondola to enforce a rate limit of 60 requests per\n60 seconds per token.\n"
  },
  "servers": [
    {
      "url": "https://gondola.cc",
      "description": "Gondola production API"
    },
    {
      "url": "http://localhost:3000",
      "description": "Local development server"
    }
  ],
  "security": [
    {
      "BearerPortfolioToken": []
    }
  ],
  "tags": [
    {
      "name": "Portfolio",
      "description": "Read-only portfolio data for custom websites, automations, and agents."
    }
  ],
  "paths": {
    "/api/v1/portfolio": {
      "get": {
        "tags": [
          "Portfolio"
        ],
        "summary": "Get portfolio feed and stats",
        "operationId": "getPortfolioFeed",
        "description": "Returns the token owner's profile summary, aggregate portfolio stats,\na recent or paginated feed of public portfolio posts, and access-tier\nmetadata describing whether the token currently has free or PRO behavior.\n\nStats behavior:\n- `data.stats` always reflects the current request criteria.\n- An unfiltered request returns overall portfolio totals.\n- A filtered request returns stats for that filtered subset only.\n- If you want to display all-time overall stats at the top of a\n  portfolio page, fetch them from an unfiltered request and do not\n  replace them with stats from later filtered requests.\n\nAgent guidance:\n- This endpoint is CORS-enabled. Direct browser `fetch()` works from\n  any origin.\n- Embed the portfolio token as a JavaScript string constant. Never use\n  `process.env` in browser code — environment variables are not\n  available in the browser.\n- Fetch once on page load and reuse the response. Do not fetch on\n  every component render or on a timer interval.\n- All response data is inside the `data` property. Access fields as\n  `data.user`, `data.stats`, `data.posts` — not as top-level\n  properties on the response object.\n- Do NOT send `sort`, `network`, `account`, `collaborator`, or `role`\n  query parameters unless `data.access.isPro` is `true`. The server\n  returns HTTP 403 for free-tier tokens that use these parameters —\n  it does not silently ignore them.\n- If the response is HTTP 429 (rate limited), show cached or\n  placeholder content. Read the `Retry-After` header for when to\n  retry.\n- `data.posts[].thumbnailUrl` and `data.user.avatarUrl` are complete\n  URLs ready to use in `<img src>`.\n- `data.posts[].credits` lists the people who worked on the post.\n  Each credit has a `.user` (name, avatarUrl) and `.role` (name).\n- Use `data.paging.nextFrom` to continue paging for PRO users.\n- Do not infer hidden or private information from missing data.\n",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Zero-based offset for pagination. Only meaningful for PRO users.\nFree users are always limited to the first page.\n",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "example": 25
          },
          {
            "name": "size",
            "in": "query",
            "required": false,
            "description": "Number of posts to return. Only meaningful for PRO users.\nFree users are always limited to 5 posts.\n",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 25
            },
            "example": 10
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort order for PRO users. Free users default to `postedAt` and\nexplicit sort requests are rejected with a 403 response.\n",
            "schema": {
              "type": "string",
              "enum": [
                "postedAt",
                "currentEngagements"
              ],
              "default": "postedAt"
            },
            "example": "currentEngagements"
          },
          {
            "name": "network",
            "in": "query",
            "required": false,
            "description": "Comma-separated network filters for PRO users. Accepts Gondola network\nids or names such as `twitter`, `instagram`, or `youtube`.\n",
            "schema": {
              "type": "string"
            },
            "example": "instagram,twitter"
          },
          {
            "name": "account",
            "in": "query",
            "required": false,
            "description": "Comma-separated posting account filters for PRO users.\n",
            "schema": {
              "type": "string"
            },
            "example": "espn,houseofhighlights"
          },
          {
            "name": "collaborator",
            "in": "query",
            "required": false,
            "description": "Comma-separated collaborator filters for PRO users. Accepts Gondola\nuser ids or usernames.\n",
            "schema": {
              "type": "string"
            },
            "example": "nhl,janedoe"
          },
          {
            "name": "role",
            "in": "query",
            "required": false,
            "description": "Comma-separated role filters for PRO users. Accepts Gondola role ids\nor exact role names.\n",
            "schema": {
              "type": "string"
            },
            "example": "Producer,Photographer"
          }
        ],
        "responses": {
          "200": {
            "description": "Portfolio feed and aggregate stats",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortfolioResponseEnvelope"
                },
                "examples": {
                  "freeTier": {
                    "summary": "Free user response",
                    "value": {
                      "success": 1,
                      "error": 0,
                      "data": {
                        "access": {
                          "tier": "free",
                          "isPro": false,
                          "canPaginate": false,
                          "maxPosts": 5
                        },
                        "user": {
                          "id": 123,
                          "name": "Jane Doe",
                          "username": "janedoe",
                          "avatarUrl": "https://img.gondola.cc/tr:w-400,h-400,fo-auto/avatars/janedoe.jpg",
                          "blurb": "The best producer in the world.",
                          "defaultRole": {
                            "id": 9,
                            "name": "Producer"
                          },
                          "profileUrl": "https://gondola.cc/janedoe"
                        },
                        "stats": {
                          "postCount": 6,
                          "views": 42000,
                          "likes": 2500,
                          "shares": 320,
                          "comments": 180
                        },
                        "posts": [
                          {
                            "id": 456,
                            "url": "https://twitter.com/t/status/example-post",
                            "gondolaUrl": "https://gondola.cc/posts/456",
                            "account": "janedoe",
                            "network": "twitter",
                            "type": "image",
                            "postedAt": "2026-03-01T14:30:00.000Z",
                            "thumbnailUrl": "https://img.gondola.cc/tr:w-,h-,fo-auto/thumbnails/example.jpg",
                            "views": 12000,
                            "likes": 950,
                            "shares": 130,
                            "comments": 40,
                            "credits": [
                              {
                                "id": 7001,
                                "user": {
                                  "id": 123,
                                  "name": "Jane Doe",
                                  "username": "janedoe",
                                  "avatarUrl": "https://img.gondola.cc/tr:w-400,h-400,fo-auto/avatars/janedoe.jpg"
                                },
                                "role": {
                                  "id": 9,
                                  "name": "Producer"
                                }
                              }
                            ]
                          }
                        ],
                        "paging": {
                          "from": 0,
                          "size": 5,
                          "returned": 5,
                          "nextFrom": null,
                          "count": 6
                        }
                      }
                    }
                  },
                  "proTier": {
                    "summary": "PRO user response with pagination",
                    "value": {
                      "success": 1,
                      "error": 0,
                      "data": {
                        "access": {
                          "tier": "pro",
                          "isPro": true,
                          "canPaginate": true,
                          "maxPosts": null
                        },
                        "user": {
                          "id": 123,
                          "name": "Jane Doe",
                          "username": "janedoe",
                          "avatarUrl": "https://img.gondola.cc/tr:w-400,h-400,fo-auto/avatars/janedoe.jpg",
                          "blurb": "The best producer in the world.",
                          "defaultRole": {
                            "id": 9,
                            "name": "Producer"
                          },
                          "profileUrl": "https://gondola.cc/janedoe"
                        },
                        "stats": {
                          "postCount": 124,
                          "views": 1220000,
                          "likes": 84000,
                          "shares": 5400,
                          "comments": 4100
                        },
                        "posts": [],
                        "paging": {
                          "from": 25,
                          "size": 25,
                          "returned": 25,
                          "nextFrom": 50,
                          "count": 124
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "XRateLimitLimit": {
        "description": "Maximum number of portfolio requests allowed per token within the current rate limit window.",
        "schema": {
          "type": "integer",
          "example": 60
        }
      },
      "XRateLimitRemaining": {
        "description": "Remaining portfolio requests available for the current token in the current rate limit window.",
        "schema": {
          "type": "integer",
          "example": 59
        }
      },
      "XRateLimitReset": {
        "description": "Number of seconds until the current token regains request capacity.",
        "schema": {
          "type": "integer",
          "example": 60
        }
      },
      "RetryAfter": {
        "description": "Number of seconds a client should wait before retrying after a rate-limited response.",
        "schema": {
          "type": "integer",
          "example": 12
        }
      }
    },
    "securitySchemes": {
      "BearerPortfolioToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "portfolio-token",
        "description": "A Gondola portfolio token created via `POST /api/token/portfolio`.\nSend it as `Authorization: Bearer <token>`.\n"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Request validation failed",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "invalidQuery": {
                "value": {
                  "success": 0,
                  "error": 1,
                  "message": "size must be a non-negative integer"
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Portfolio token is missing or invalid",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "missingOrInvalidToken": {
                "value": {
                  "success": 0,
                  "error": 1,
                  "message": "Invalid portfolio token"
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Request is authenticated but not allowed for this access tier",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "freeTierFilters": {
                "value": {
                  "success": 0,
                  "error": 1,
                  "message": "Portfolio API filters require a PRO subscription"
                }
              },
              "freeTierSort": {
                "value": {
                  "success": 0,
                  "error": 1,
                  "message": "Portfolio API sorting requires a PRO subscription"
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Portfolio token has exceeded the rate limit for the current sliding window",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/XRateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/XRateLimitRemaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/XRateLimitReset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "tooManyRequests": {
                "value": {
                  "success": 0,
                  "error": 1,
                  "message": "Portfolio API rate limit exceeded"
                }
              }
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    },
    "schemas": {
      "PortfolioResponseEnvelope": {
        "type": "object",
        "required": [
          "success",
          "error",
          "data"
        ],
        "properties": {
          "success": {
            "type": "integer",
            "format": "int32",
            "example": 1
          },
          "error": {
            "type": "integer",
            "format": "int32",
            "example": 0
          },
          "data": {
            "$ref": "#/components/schemas/PortfolioResponse"
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "properties": {
          "success": {
            "type": "integer",
            "format": "int32",
            "example": 0
          },
          "error": {
            "type": "integer",
            "format": "int32",
            "example": 1
          },
          "message": {
            "type": "string",
            "example": "Invalid portfolio token"
          }
        }
      },
      "PortfolioResponse": {
        "type": "object",
        "required": [
          "access",
          "user",
          "stats",
          "posts",
          "paging"
        ],
        "properties": {
          "access": {
            "$ref": "#/components/schemas/PortfolioAccess"
          },
          "user": {
            "$ref": "#/components/schemas/PortfolioUser"
          },
          "stats": {
            "$ref": "#/components/schemas/PortfolioStats"
          },
          "posts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PortfolioPost"
            }
          },
          "paging": {
            "$ref": "#/components/schemas/PortfolioPaging"
          }
        }
      },
      "PortfolioAccess": {
        "type": "object",
        "required": [
          "tier",
          "isPro",
          "canPaginate",
          "maxPosts"
        ],
        "properties": {
          "tier": {
            "type": "string",
            "enum": [
              "free",
              "pro"
            ],
            "description": "Current behavior tier applied to this token owner."
          },
          "isPro": {
            "type": "boolean",
            "description": "Whether the token owner currently has PRO access."
          },
          "canPaginate": {
            "type": "boolean",
            "description": "Whether `from` and `size` pagination controls are honored."
          },
          "maxPosts": {
            "type": "integer",
            "nullable": true,
            "description": "Maximum posts returned for non-paginated access tiers.\n`null` means no special cap beyond normal page size behavior.\n"
          }
        }
      },
      "PortfolioUser": {
        "type": "object",
        "required": [
          "id",
          "name",
          "username",
          "avatarUrl",
          "defaultRole"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "name": {
            "type": "string"
          },
          "username": {
            "type": "string",
            "nullable": true
          },
          "avatarUrl": {
            "type": "string",
            "description": "Fully qualified avatar URL suitable for rendering."
          },
          "blurb": {
            "type": "string",
            "nullable": true
          },
          "defaultRole": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PortfolioRole"
              }
            ],
            "nullable": true
          },
          "profileUrl": {
            "type": "string",
            "nullable": true,
            "description": "Absolute URL for the user's Gondola profile."
          }
        }
      },
      "PortfolioStats": {
        "type": "object",
        "required": [
          "postCount",
          "views",
          "likes",
          "shares",
          "comments"
        ],
        "properties": {
          "postCount": {
            "type": "integer"
          },
          "views": {
            "type": "integer"
          },
          "likes": {
            "type": "integer"
          },
          "shares": {
            "type": "integer"
          },
          "comments": {
            "type": "integer"
          }
        }
      },
      "PortfolioPost": {
        "type": "object",
        "required": [
          "id",
          "gondolaUrl",
          "account",
          "network",
          "type",
          "postedAt",
          "thumbnailUrl",
          "views",
          "likes",
          "shares",
          "comments",
          "credits"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "url": {
            "type": "string",
            "nullable": true,
            "description": "Original social post URL when available."
          },
          "gondolaUrl": {
            "type": "string",
            "description": "Absolute Gondola URL for the post."
          },
          "account": {
            "type": "string"
          },
          "network": {
            "type": "string",
            "enum": [
              "twitter",
              "instagram",
              "facebook",
              "youtube",
              "tiktok",
              "behance",
              "personal",
              "linkedin",
              "twitch",
              "threads",
              "snapchat"
            ],
            "description": "Social network the post was published on."
          },
          "type": {
            "type": "string",
            "enum": [
              "video",
              "gif",
              "image",
              "text",
              "story"
            ],
            "description": "Content type of the post."
          },
          "postedAt": {
            "type": "string",
            "format": "date-time"
          },
          "thumbnailUrl": {
            "type": "string",
            "description": "Fully qualified thumbnail URL suitable for rendering."
          },
          "views": {
            "type": "integer"
          },
          "likes": {
            "type": "integer"
          },
          "shares": {
            "type": "integer"
          },
          "comments": {
            "type": "integer"
          },
          "credits": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PortfolioCredit"
            }
          }
        }
      },
      "PortfolioCredit": {
        "type": "object",
        "required": [
          "id",
          "user",
          "role"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "user": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PortfolioCreditUser"
              }
            ],
            "nullable": true
          },
          "role": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PortfolioRole"
              }
            ],
            "nullable": true
          }
        }
      },
      "PortfolioCreditUser": {
        "type": "object",
        "required": [
          "id",
          "name",
          "username",
          "avatarUrl"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "name": {
            "type": "string"
          },
          "username": {
            "type": "string",
            "nullable": true
          },
          "avatarUrl": {
            "type": "string",
            "description": "Fully qualified avatar URL suitable for rendering."
          }
        }
      },
      "PortfolioRole": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "name": {
            "type": "string"
          }
        }
      },
      "PortfolioPaging": {
        "type": "object",
        "required": [
          "from",
          "size",
          "returned",
          "nextFrom",
          "count"
        ],
        "properties": {
          "from": {
            "type": "integer"
          },
          "size": {
            "type": "integer"
          },
          "returned": {
            "type": "integer"
          },
          "nextFrom": {
            "type": "integer",
            "nullable": true,
            "description": "Offset for the next page. `null` means there is no next page or\npagination is not available for this access tier.\n"
          },
          "count": {
            "type": "integer"
          }
        }
      }
    }
  }
}