diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f0a4ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,63 @@ +# Python cache +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Environment variables +.env +.env.local +.env.*.local + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation (keep README.md for hatchling build) +docs/ +examples/ + +# Tests +tests/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Build artifacts +build/ +dist/ +*.egg-info/ +.eggs/ + +# Type checking +.mypy_cache/ +.pytype/ +.pyre/ +.dmypy.json + +# Linting +.ruff_cache/ + +# CI/CD +.github/ +.gitlab-ci.yml + +# Misc +*.log +.DS_Store diff --git a/.env.example b/.env.example index f1d3da4..09cfcde 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ -# Late API Configuration +# Late API Configuration (for STDIO mode with Claude Desktop) +# For HTTP/SSE mode, users provide their API key via request headers LATE_API_KEY=your_api_key_here +# Server Configuration (optional, has defaults) +HOST=0.0.0.0 +PORT=8080 + # AI Provider (optional - for content generation) OPENAI_API_KEY=sk-your_openai_key_here # ANTHROPIC_API_KEY=sk-ant-your_anthropic_key_here diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml new file mode 100644 index 0000000..df0400d --- /dev/null +++ b/.github/workflows/generate.yml @@ -0,0 +1,180 @@ +name: Regenerate + +on: + # Triggered by the API repository when OpenAPI spec changes + repository_dispatch: + types: [openapi-updated] + + # Allow manual trigger + workflow_dispatch: + +jobs: + generate: + name: Regenerate from OpenAPI + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Fetch latest OpenAPI spec + run: | + curl -f -o openapi.yaml https://zernio.com/openapi.yaml + echo "Fetched OpenAPI spec:" + head -20 openapi.yaml + + - name: Generate resources + run: | + echo "Starting resource generation..." + uv run python scripts/generate_resources.py + echo "Resource generation complete." + + - name: Generate models + run: | + echo "Starting model generation..." + uv run python scripts/generate_models.py || echo "Model generation skipped (requires local docs repo)" + echo "Model generation complete." + + - name: Generate MCP tools + run: | + echo "Starting MCP tool generation..." + uv run --with pyyaml python scripts/generate_mcp_tools.py + echo "MCP tool generation complete." + + - name: Generate README SDK Reference + run: | + echo "Starting README SDK Reference generation..." + uv run python scripts/generate_readme_reference.py + echo "README SDK Reference generation complete." + + - name: Run linter (fix formatting) + run: | + uv run ruff check --fix src/late/resources/_generated/ || true + uv run ruff format src/late/resources/_generated/ || true + uv run ruff check --fix src/late/mcp/generated_tools.py || true + uv run ruff format src/late/mcp/generated_tools.py || true + + - name: Run tests + run: uv run pytest tests -v --tb=short + + - name: Check for changes + id: changes + run: | + git add -A + if git diff --staged --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "Changes detected:" + git diff --staged --name-only + fi + + - name: Bump version and commit + if: steps.changes.outputs.has_changes == 'true' + id: version + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Get current version + CURRENT_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "Current version: $CURRENT_VERSION" + + # Bump patch version + IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION" + MAJOR="${VERSION_PARTS[0]}" + MINOR="${VERSION_PARTS[1]}" + PATCH="${VERSION_PARTS[2]}" + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" + echo "New version: $NEW_VERSION" + + # Update version in pyproject.toml + sed -i "s/version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" pyproject.toml + + # Also update __init__.py if it has a version + sed -i "s/__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/" src/late/__init__.py || true + + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + # Stage all changes + git add -A + + # Commit + git commit -m "chore: regenerate from OpenAPI spec + + - Auto-generated SDK updates + - Version: $NEW_VERSION" + git push + + - name: Build package + if: steps.changes.outputs.has_changes == 'true' + run: uv build + + - name: Create GitHub Release + if: steps.changes.outputs.has_changes == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.new_version }} + name: v${{ steps.version.outputs.new_version }} + files: dist/* + generate_release_notes: true + body: | + ## Auto-generated SDK Update + + This release was automatically generated from the latest OpenAPI spec. + + This package is published under two names: + + ```bash + pip install late-sdk==${{ steps.version.outputs.new_version }} + # or + pip install zernio-sdk==${{ steps.version.outputs.new_version }} + ``` + + - name: Publish late-sdk to PyPI + if: steps.changes.outputs.has_changes == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Swap package name to zernio-sdk + if: steps.changes.outputs.has_changes == 'true' + run: | + python3 -c " + import re + with open('pyproject.toml', 'r') as f: + content = f.read() + content = re.sub(r'name = \"late-sdk\"', 'name = \"zernio-sdk\"', content) + with open('pyproject.toml', 'w') as f: + f.write(content) + " + + - name: Build as zernio-sdk + if: steps.changes.outputs.has_changes == 'true' + run: | + rm -rf dist/ + uv build + + - name: Publish zernio-sdk to PyPI + if: steps.changes.outputs.has_changes == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dc85f5..253dccc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,8 +94,42 @@ jobs: name: v${{ steps.version.outputs.version }} files: dist/* generate_release_notes: true + body: | + ## Install - - name: Publish to PyPI + This package is published under two names: + + ```bash + pip install late-sdk==${{ steps.version.outputs.version }} + # or + pip install zernio-sdk==${{ steps.version.outputs.version }} + ``` + + - name: Publish late-sdk to PyPI + if: steps.check_tag.outputs.exists == 'false' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Swap package name to zernio-sdk + if: steps.check_tag.outputs.exists == 'false' + run: | + python3 -c " + import re + with open('pyproject.toml', 'r') as f: + content = f.read() + content = re.sub(r'name = \"late-sdk\"', 'name = \"zernio-sdk\"', content) + with open('pyproject.toml', 'w') as f: + f.write(content) + " + + - name: Build as zernio-sdk + if: steps.check_tag.outputs.exists == 'false' + run: | + rm -rf dist/ + uv build + + - name: Publish zernio-sdk to PyPI if: steps.check_tag.outputs.exists == 'false' uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e16e0a1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to the Late Python SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.2.0] - 2025-01-16 + +### Added +- MCP (Model Context Protocol) server support for Claude Desktop integration +- HTTP server mode for MCP (`late-mcp-http`) +- AI content generation module with OpenAI and Anthropic support +- Pipelines for common workflows (CSV scheduler, cross-poster) +- Large file upload support via Vercel Blob +- Progress callbacks for file uploads +- Comprehensive test suite with pytest + +### Changed +- Improved error handling with specialized exception classes +- Better async support throughout the SDK + +## [1.1.0] - 2025-01-10 + +### Added +- Upload module with `SmartUploader` for automatic upload strategy selection +- Direct upload for files under 4MB +- Multipart upload support for large files +- `upload_bytes()` and `upload_large_bytes()` methods + +## [1.0.0] - 2025-01-01 + +### Added +- Initial public release +- Full coverage of Late API endpoints +- Support for all 13 social media platforms: Instagram, TikTok, YouTube, LinkedIn, X/Twitter, Facebook, Pinterest, Threads, Bluesky, Reddit, Snapchat, Telegram, and Google Business Profile +- Async support with `alist()`, `acreate()`, etc. +- Type hints and Pydantic models +- Error handling with `LateAPIError`, `LateRateLimitError`, `LateValidationError` + +### API Coverage +- Posts: create, list, get, update, delete, retry, bulk upload +- Accounts: list, get, follower stats +- Profiles: create, list, get, update, delete +- Analytics: get metrics, usage stats +- Account Groups: create, list, update, delete +- Queue: slots management, preview +- Webhooks: settings management, logs, testing +- API Keys: create, list, delete +- Media: upload, generate upload token +- Tools: downloads, hashtag checking, transcripts, AI caption generation +- Users: list, get +- Usage: stats +- Logs: list, get +- Connect: OAuth flows for all platforms +- Reddit: feed, search +- Invites: platform invites management diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..edbe073 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Late MCP HTTP Server - Railway Deployment +# Uses uv for fast, reliable dependency management + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src + +# Install dependencies (no dev dependencies in production) +RUN uv sync --frozen --no-dev --extra mcp + +# Expose port (Railway will set PORT env var) +EXPOSE 8080 + +# Health check for Railway monitoring (Railway will use /health endpoint) + +# Run HTTP server (Railway sets PORT env var automatically) +CMD ["sh", "-c", "uv run late-mcp-http --host 0.0.0.0 --port ${PORT:-8080}"] \ No newline at end of file diff --git a/Dockerfile.docker b/Dockerfile.docker new file mode 100644 index 0000000..4436914 --- /dev/null +++ b/Dockerfile.docker @@ -0,0 +1,22 @@ +# Late MCP HTTP Server - Railway Deployment +# Uses uv for fast, reliable dependency management + +FROM ghcr.io/astral-sh/uv:python3.12-slim + +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src + +# Install dependencies (no dev dependencies in production) +RUN --mount=type=cache,id=uv-cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --extra mcp + +# Expose port (Railway will set PORT env var) +EXPOSE 8080 + +# Health check for Railway monitoring (Railway will use /health endpoint) + +# Run HTTP server (Railway sets PORT env var automatically) +CMD ["sh", "-c", "uv run late-mcp-http --host 0.0.0.0 --port ${PORT:-8080}"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84d24bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 Late + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 64a4a0b..6c5bfd9 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,133 @@

- Late + + Zernio +

-

Late Python SDK

+

Zernio Python SDK

- Python SDK for Late API - Schedule social media posts across multiple platforms. + PyPI version + License

+

+ One API to post everywhere. 14 platforms, zero headaches. +

+ +The official Python SDK for the [Zernio API](https://zernio.com) — schedule and publish social media posts across Instagram, TikTok, YouTube, LinkedIn, X/Twitter, Facebook, Pinterest, Threads, Bluesky, Reddit, Snapchat, Telegram, WhatsApp, and Google Business Profile with a single integration. + ## Installation ```bash -pip install late-sdk +pip install zernio-sdk ``` ## Quick Start ```python -from datetime import datetime, timedelta -from late import Late, Platform - -client = Late(api_key="your_api_key") +from late import Late -# List connected accounts -accounts = client.accounts.list() +late = Late(api_key="your-api-key") -# Create a scheduled post -post = client.posts.create( - content="Hello from Late!", - platforms=[{"platform": Platform.TWITTER, "accountId": "your_account_id"}], - scheduled_for=datetime.now() + timedelta(hours=1), +# Publish to multiple platforms with one call +post = late.posts.create( + content="Hello world from Late!", + platforms=[ + {"platform": "twitter", "accountId": "acc_xxx"}, + {"platform": "linkedin", "accountId": "acc_yyy"}, + {"platform": "instagram", "accountId": "acc_zzz"}, + ], + publish_now=True, ) -``` ---- - -## šŸ¤– Claude Desktop Integration (MCP) - -Schedule posts directly from Claude Desktop using natural language. - -### Setup in 3 Steps - -**1. Install uv** (package manager) - -```bash -# macOS / Linux -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Windows (PowerShell) -powershell -c "irm https://astral.sh/uv/install.ps1 | iex" +print(f"Published to {len(post['post']['platforms'])} platforms!") ``` -**2. Add to Claude Desktop config** - -Open the config file: -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -Add this: - -```json -{ - "mcpServers": { - "late": { - "command": "uvx", - "args": ["--from", "late-sdk[mcp]", "late-mcp"], - "env": { - "LATE_API_KEY": "your_api_key_here" - } - } - } -} +## Configuration + +```python +late = Late( + api_key="your-api-key", # Required + base_url="https://zernio.com/api", # Optional, this is the default + timeout=30.0, # Optional, request timeout in seconds +) ``` -> Get your API key at [getlate.dev/dashboard/api-keys](https://getlate.dev/dashboard/api-keys) +## Examples -**3. Restart Claude Desktop** +### Schedule a Post -Done! Ask Claude things like: -- *"Post 'Hello world!' to Twitter"* -- *"Schedule a LinkedIn post for tomorrow at 9am"* -- *"Show my connected accounts"* +```python +post = late.posts.create( + content="This post will go live tomorrow at 10am", + platforms=[{"platform": "instagram", "accountId": "acc_xxx"}], + scheduled_for="2025-02-01T10:00:00Z", +) +``` -
-Alternative: Using pip instead of uvx +### Platform-Specific Content -```bash -pip install late-sdk[mcp] -``` +Customize content per platform while posting to all at once: -```json -{ - "mcpServers": { - "late": { - "command": "python", - "args": ["-m", "late.mcp"], - "env": { - "LATE_API_KEY": "your_api_key_here" - } - } - } -} +```python +post = late.posts.create( + content="Default content", + platforms=[ + { + "platform": "twitter", + "accountId": "acc_twitter", + "platformSpecificContent": "Short & punchy for X", + }, + { + "platform": "linkedin", + "accountId": "acc_linkedin", + "platformSpecificContent": "Professional tone for LinkedIn with more detail.", + }, + ], + publish_now=True, +) ``` -
+### Upload Media -### Uploading Images/Videos +```python +# Option 1: Direct upload (simplest) +result = late.media.upload("path/to/video.mp4") +media_url = result["publicUrl"] + +# Option 2: Upload from bytes +result = late.media.upload_bytes(video_bytes, "video.mp4", "video/mp4") +media_url = result["publicUrl"] + +# Create post with media +post = late.posts.create( + content="Check out this video!", + media_urls=[media_url], + platforms=[ + {"platform": "tiktok", "accountId": "acc_xxx"}, + {"platform": "youtube", "accountId": "acc_yyy", "youtubeTitle": "My Video"}, + ], + publish_now=True, +) +``` -Since Claude can't access local files, use the browser upload flow: +### Get Analytics -1. Ask Claude: *"I want to post an image to Instagram"* -2. Claude gives you an upload link → open it in your browser -3. Upload your file and tell Claude *"done"* -4. Claude creates the post with your media +```python +data = late.analytics.get(period="30d") -### Available Commands +print("Analytics:", data) +``` -| Command | What it does | -|---------|--------------| -| `accounts_list` | Show connected social accounts | -| `posts_create` | Create scheduled, immediate, or draft post | -| `posts_publish_now` | Publish immediately | -| `posts_cross_post` | Post to multiple platforms | -| `posts_list` | Show your posts | -| `posts_retry` | Retry a failed post | -| `media_generate_upload_link` | Get link to upload media | +### List Connected Accounts ---- +```python +data = late.accounts.list() -## SDK Features +for account in data["accounts"]: + print(f"{account['platform']}: @{account['username']}") +``` ### Async Support @@ -137,106 +136,355 @@ import asyncio from late import Late async def main(): - async with Late(api_key="...") as client: - posts = await client.posts.alist(status="scheduled") + async with Late(api_key="your-api-key") as late: + posts = await late.posts.alist(status="scheduled") + print(f"Found {len(posts['posts'])} scheduled posts") asyncio.run(main()) ``` -### AI Content Generation (Experimental) - -```bash -pip install late-sdk[ai] -``` +## Error Handling ```python -from late import Platform, CaptionTone -from late.ai import ContentGenerator, GenerateRequest - -generator = ContentGenerator( - provider="openai", - api_key="sk-...", - model="gpt-4o-mini", # or gpt-4o, gpt-4-turbo, etc. -) - -response = generator.generate( - GenerateRequest( - prompt="Write a tweet about Python", - platform=Platform.TWITTER, - tone=CaptionTone.CASUAL, - ) -) - -print(response.text) -``` - -### CSV Scheduling - -```python -from late import Late -from late.pipelines import CSVSchedulerPipeline - -client = Late(api_key="...") -pipeline = CSVSchedulerPipeline(client) - -# Validate first -results = pipeline.schedule("posts.csv", dry_run=True) - -# Then schedule -results = pipeline.schedule("posts.csv") +from late import Late, LateAPIError, LateRateLimitError, LateValidationError + +late = Late(api_key="your-api-key") + +try: + late.posts.create(content="Hello!", platforms=[...]) +except LateRateLimitError as e: + print(f"Rate limited: {e}") +except LateValidationError as e: + print(f"Invalid request: {e}") +except LateAPIError as e: + print(f"API error: {e}") ``` -### Cross-Posting - -```python -from late import Platform -from late.pipelines import CrossPosterPipeline, PlatformConfig - -cross_poster = CrossPosterPipeline(client) +## SDK Reference + +### Posts +| Method | Description | +|--------|-------------| +| `posts.list_posts()` | List posts | +| `posts.bulk_upload_posts()` | Bulk upload from CSV | +| `posts.create_post()` | Create post | +| `posts.get_post()` | Get post | +| `posts.update_post()` | Update post | +| `posts.delete_post()` | Delete post | +| `posts.retry_post()` | Retry failed post | +| `posts.unpublish_post()` | Unpublish post | + +### Accounts +| Method | Description | +|--------|-------------| +| `accounts.get_all_accounts_health()` | Check accounts health | +| `accounts.list_accounts()` | List accounts | +| `accounts.get_account_health()` | Check account health | +| `accounts.get_follower_stats()` | Get follower stats | +| `accounts.get_google_business_reviews()` | Get reviews | +| `accounts.get_linked_in_mentions()` | Resolve LinkedIn mention | +| `accounts.update_account()` | Update account | +| `accounts.delete_account()` | Disconnect account | + +### Profiles +| Method | Description | +|--------|-------------| +| `profiles.list_profiles()` | List profiles | +| `profiles.create_profile()` | Create profile | +| `profiles.get_profile()` | Get profile | +| `profiles.update_profile()` | Update profile | +| `profiles.delete_profile()` | Delete profile | + +### Analytics +| Method | Description | +|--------|-------------| +| `analytics.get_analytics()` | Get post analytics | +| `analytics.get_best_time_to_post()` | Get best times to post | +| `analytics.get_content_decay()` | Get content performance decay | +| `analytics.get_daily_metrics()` | Get daily aggregated metrics | +| `analytics.get_linked_in_aggregate_analytics()` | Get LinkedIn aggregate stats | +| `analytics.get_linked_in_post_analytics()` | Get LinkedIn post stats | +| `analytics.get_linked_in_post_reactions()` | Get LinkedIn post reactions | +| `analytics.get_post_timeline()` | Get post analytics timeline | +| `analytics.get_posting_frequency()` | Get posting frequency vs engagement | +| `analytics.get_you_tube_daily_views()` | Get YouTube daily views | + +### Account Groups +| Method | Description | +|--------|-------------| +| `account_groups.list_account_groups()` | List groups | +| `account_groups.create_account_group()` | Create group | +| `account_groups.update_account_group()` | Update group | +| `account_groups.delete_account_group()` | Delete group | + +### Queue +| Method | Description | +|--------|-------------| +| `queue.list_queue_slots()` | List schedules | +| `queue.create_queue_slot()` | Create schedule | +| `queue.get_next_queue_slot()` | Get next available slot | +| `queue.update_queue_slot()` | Update schedule | +| `queue.delete_queue_slot()` | Delete schedule | +| `queue.preview_queue()` | Preview upcoming slots | + +### Webhooks +| Method | Description | +|--------|-------------| +| `webhooks.create_webhook_settings()` | Create webhook | +| `webhooks.get_webhook_logs()` | Get delivery logs | +| `webhooks.get_webhook_settings()` | List webhooks | +| `webhooks.update_webhook_settings()` | Update webhook | +| `webhooks.delete_webhook_settings()` | Delete webhook | +| `webhooks.test_webhook()` | Send test webhook | + +### API Keys +| Method | Description | +|--------|-------------| +| `api_keys.list_api_keys()` | List keys | +| `api_keys.create_api_key()` | Create key | +| `api_keys.delete_api_key()` | Delete key | + +### Media +| Method | Description | +|--------|-------------| +| `media.get_media_presigned_url()` | Get presigned upload URL | +| `media.upload()` | Upload a file from path | +| `media.upload_bytes()` | Upload file from bytes | +| `media.upload_large()` | Upload large file with multipart | +| `media.upload_large_bytes()` | Upload large file from bytes | +| `media.upload_multiple()` | Upload multiple files | + +### Tools +| Method | Description | +|--------|-------------| +| `tools.get_you_tube_transcript()` | Get YouTube transcript | +| `tools.check_instagram_hashtags()` | Check IG hashtag bans | +| `tools.download_bluesky_media()` | Download Bluesky media | +| `tools.download_facebook_video()` | Download Facebook video | +| `tools.download_instagram_media()` | Download Instagram media | +| `tools.download_linked_in_video()` | Download LinkedIn video | +| `tools.download_tik_tok_video()` | Download TikTok video | +| `tools.download_twitter_media()` | Download Twitter/X media | +| `tools.download_you_tube_video()` | Download YouTube video | + +### Users +| Method | Description | +|--------|-------------| +| `users.list_users()` | List users | +| `users.get_user()` | Get user | + +### Usage +| Method | Description | +|--------|-------------| +| `usage.get_usage_stats()` | Get plan and usage stats | + +### Logs +| Method | Description | +|--------|-------------| +| `logs.list_connection_logs()` | List connection logs | +| `logs.list_posts_logs()` | List publishing logs | +| `logs.get_post_logs()` | Get post logs | + +### Connect (OAuth) +| Method | Description | +|--------|-------------| +| `connect.list_facebook_pages()` | List Facebook pages | +| `connect.list_google_business_locations()` | List GBP locations | +| `connect.list_linked_in_organizations()` | List LinkedIn orgs | +| `connect.list_pinterest_boards_for_selection()` | List Pinterest boards | +| `connect.list_snapchat_profiles()` | List Snapchat profiles | +| `connect.get_connect_url()` | Get OAuth connect URL | +| `connect.get_facebook_pages()` | List Facebook pages | +| `connect.get_gmb_locations()` | List GBP locations | +| `connect.get_linked_in_organizations()` | List LinkedIn orgs | +| `connect.get_pending_o_auth_data()` | Get pending OAuth data | +| `connect.get_pinterest_boards()` | List Pinterest boards | +| `connect.get_reddit_flairs()` | List subreddit flairs | +| `connect.get_reddit_subreddits()` | List Reddit subreddits | +| `connect.get_telegram_connect_status()` | Generate Telegram code | +| `connect.update_facebook_page()` | Update Facebook page | +| `connect.update_gmb_location()` | Update GBP location | +| `connect.update_linked_in_organization()` | Switch LinkedIn account type | +| `connect.update_pinterest_boards()` | Set default Pinterest board | +| `connect.update_reddit_subreddits()` | Set default subreddit | +| `connect.complete_telegram_connect()` | Check Telegram status | +| `connect.connect_bluesky_credentials()` | Connect Bluesky account | +| `connect.connect_whats_app_credentials()` | Connect WhatsApp via credentials | +| `connect.handle_o_auth_callback()` | Complete OAuth callback | +| `connect.initiate_telegram_connect()` | Connect Telegram directly | +| `connect.select_facebook_page()` | Select Facebook page | +| `connect.select_google_business_location()` | Select GBP location | +| `connect.select_linked_in_organization()` | Select LinkedIn org | +| `connect.select_pinterest_board()` | Select Pinterest board | +| `connect.select_snapchat_profile()` | Select Snapchat profile | + +### Reddit +| Method | Description | +|--------|-------------| +| `reddit.get_reddit_feed()` | Get subreddit feed | +| `reddit.search_reddit()` | Search posts | + +### Account Settings +| Method | Description | +|--------|-------------| +| `account_settings.get_instagram_ice_breakers()` | Get IG ice breakers | +| `account_settings.get_messenger_menu()` | Get FB persistent menu | +| `account_settings.get_telegram_commands()` | Get TG bot commands | +| `account_settings.delete_instagram_ice_breakers()` | Delete IG ice breakers | +| `account_settings.delete_messenger_menu()` | Delete FB persistent menu | +| `account_settings.delete_telegram_commands()` | Delete TG bot commands | +| `account_settings.set_instagram_ice_breakers()` | Set IG ice breakers | +| `account_settings.set_messenger_menu()` | Set FB persistent menu | +| `account_settings.set_telegram_commands()` | Set TG bot commands | + +### Comments (Inbox) +| Method | Description | +|--------|-------------| +| `comments.list_inbox_comments()` | List commented posts | +| `comments.get_inbox_post_comments()` | Get post comments | +| `comments.delete_inbox_comment()` | Delete comment | +| `comments.hide_inbox_comment()` | Hide comment | +| `comments.like_inbox_comment()` | Like comment | +| `comments.reply_to_inbox_post()` | Reply to comment | +| `comments.send_private_reply_to_comment()` | Send private reply | +| `comments.unhide_inbox_comment()` | Unhide comment | +| `comments.unlike_inbox_comment()` | Unlike comment | + +### GMB Attributes +| Method | Description | +|--------|-------------| +| `gmb_attributes.get_google_business_attributes()` | Get attributes | +| `gmb_attributes.update_google_business_attributes()` | Update attributes | + +### GMB Food Menus +| Method | Description | +|--------|-------------| +| `gmb_food_menus.get_google_business_food_menus()` | Get food menus | +| `gmb_food_menus.update_google_business_food_menus()` | Update food menus | + +### GMB Location Details +| Method | Description | +|--------|-------------| +| `gmb_location_details.get_google_business_location_details()` | Get location details | +| `gmb_location_details.update_google_business_location_details()` | Update location details | + +### GMB Media +| Method | Description | +|--------|-------------| +| `gmb_media.list_google_business_media()` | List media | +| `gmb_media.create_google_business_media()` | Upload photo | +| `gmb_media.delete_google_business_media()` | Delete photo | + +### GMB Place Actions +| Method | Description | +|--------|-------------| +| `gmb_place_actions.list_google_business_place_actions()` | List action links | +| `gmb_place_actions.create_google_business_place_action()` | Create action link | +| `gmb_place_actions.delete_google_business_place_action()` | Delete action link | + +### Messages (Inbox) +| Method | Description | +|--------|-------------| +| `messages.list_inbox_conversations()` | List conversations | +| `messages.get_inbox_conversation()` | Get conversation | +| `messages.get_inbox_conversation_messages()` | List messages | +| `messages.update_inbox_conversation()` | Update conversation status | +| `messages.edit_inbox_message()` | Edit message | +| `messages.send_inbox_message()` | Send message | + +### Reviews (Inbox) +| Method | Description | +|--------|-------------| +| `reviews.list_inbox_reviews()` | List reviews | +| `reviews.delete_inbox_review_reply()` | Delete review reply | +| `reviews.reply_to_inbox_review()` | Reply to review | + +### Twitter Engagement +| Method | Description | +|--------|-------------| +| `twitter_engagement.bookmark_post()` | Bookmark a tweet | +| `twitter_engagement.follow_user()` | Follow a user | +| `twitter_engagement.remove_bookmark()` | Remove bookmark | +| `twitter_engagement.retweet_post()` | Retweet a post | +| `twitter_engagement.undo_retweet()` | Undo retweet | +| `twitter_engagement.unfollow_user()` | Unfollow a user | + +### Validate +| Method | Description | +|--------|-------------| +| `validate.validate_media()` | Validate media URL | +| `validate.validate_post()` | Validate post content | +| `validate.validate_post_length()` | Validate post character count | +| `validate.validate_subreddit()` | Check subreddit existence | + +### WhatsApp +| Method | Description | +|--------|-------------| +| `whatsapp.bulk_delete_whats_app_contacts()` | Bulk delete contacts | +| `whatsapp.bulk_update_whats_app_contacts()` | Bulk update contacts | +| `whatsapp.create_whats_app_broadcast()` | Create broadcast | +| `whatsapp.create_whats_app_contact()` | Create contact | +| `whatsapp.create_whats_app_template()` | Create template | +| `whatsapp.get_whats_app_broadcast()` | Get broadcast | +| `whatsapp.get_whats_app_broadcast_recipients()` | List recipients | +| `whatsapp.get_whats_app_broadcasts()` | List broadcasts | +| `whatsapp.get_whats_app_business_profile()` | Get business profile | +| `whatsapp.get_whats_app_contact()` | Get contact | +| `whatsapp.get_whats_app_contacts()` | List contacts | +| `whatsapp.get_whats_app_display_name()` | Get display name and review status | +| `whatsapp.get_whats_app_groups()` | List contact groups | +| `whatsapp.get_whats_app_template()` | Get template | +| `whatsapp.get_whats_app_templates()` | List templates | +| `whatsapp.update_whats_app_business_profile()` | Update business profile | +| `whatsapp.update_whats_app_contact()` | Update contact | +| `whatsapp.update_whats_app_display_name()` | Request display name change | +| `whatsapp.update_whats_app_template()` | Update template | +| `whatsapp.delete_whats_app_broadcast()` | Delete broadcast | +| `whatsapp.delete_whats_app_contact()` | Delete contact | +| `whatsapp.delete_whats_app_group()` | Delete group | +| `whatsapp.delete_whats_app_template()` | Delete template | +| `whatsapp.add_whats_app_broadcast_recipients()` | Add recipients | +| `whatsapp.cancel_whats_app_broadcast_schedule()` | Cancel scheduled broadcast | +| `whatsapp.import_whats_app_contacts()` | Bulk import contacts | +| `whatsapp.remove_whats_app_broadcast_recipients()` | Remove recipients | +| `whatsapp.rename_whats_app_group()` | Rename group | +| `whatsapp.schedule_whats_app_broadcast()` | Schedule broadcast | +| `whatsapp.send_whats_app_broadcast()` | Send broadcast | +| `whatsapp.send_whats_app_bulk()` | Bulk send template messages | +| `whatsapp.upload_whats_app_profile_photo()` | Upload profile picture | + +### WhatsApp Phone Numbers +| Method | Description | +|--------|-------------| +| `whatsapp_phone_numbers.get_whats_app_phone_number()` | Get phone number | +| `whatsapp_phone_numbers.get_whats_app_phone_numbers()` | List phone numbers | +| `whatsapp_phone_numbers.purchase_whats_app_phone_number()` | Purchase phone number | +| `whatsapp_phone_numbers.release_whats_app_phone_number()` | Release phone number | + +### Invites +| Method | Description | +|--------|-------------| +| `invites.create_invite_token()` | Create invite token | + +## MCP Server (Claude Desktop) + +The SDK includes a Model Context Protocol (MCP) server for integration with Claude Desktop. See [MCP documentation](https://docs.zernio.com/resources/mcp) for setup instructions. -results = await cross_poster.post( - content="Big announcement!", - platforms=[ - PlatformConfig(Platform.TWITTER, "tw_123"), - PlatformConfig(Platform.LINKEDIN, "li_456", delay_minutes=5), - ], -) +```bash +pip install zernio-sdk[mcp] ``` ---- - -## API Reference - -### Resources - -| Resource | Methods | -|----------|---------| -| `client.posts` | `list`, `get`, `create`, `update`, `delete`, `retry` | -| `client.profiles` | `list`, `get`, `create`, `update`, `delete` | -| `client.accounts` | `list`, `get` | -| `client.media` | `upload`, `upload_multiple` | -| `client.analytics` | `get`, `get_usage` | -| `client.tools` | `youtube_download`, `instagram_download`, `tiktok_download`, `generate_caption` | -| `client.queue` | `get_slots`, `preview`, `next_slot` | - -### Client Options - -```python -client = Late( - api_key="...", - timeout=30.0, # seconds - max_retries=3, -) -``` +## Requirements ---- +- Python 3.10+ +- [Zernio API key](https://zernio.com) (free tier available) ## Links -- [Late Website](https://getlate.dev) -- [API Documentation](https://docs.getlate.dev) -- [Get API Key](https://getlate.dev/dashboard/api-keys) +- [Documentation](https://docs.zernio.com) +- [Dashboard](https://zernio.com/dashboard) +- [Changelog](https://docs.zernio.com/changelog) ## License -MIT +Apache-2.0 diff --git a/claude-desktop-config.json b/claude-desktop-config.json index 0ec0afe..4478c95 100644 --- a/claude-desktop-config.json +++ b/claude-desktop-config.json @@ -1,15 +1,12 @@ { "mcpServers": { - "late": { - "command": "uv", + "zernio": { + "command": "uvx", "args": [ - "run", - "--directory", - "/Users/carlos/Documents/WebDev/Freelance/miquel-palet/late-python-starter", - "late-mcp" + "zernio-mcp" ], "env": { - "LATE_API_KEY": "YOUR_API_KEY_HERE" + "ZERNIO_API_KEY": "YOUR_API_KEY_HERE" } } } diff --git a/docs/HTTP_DEPLOYMENT.md b/docs/HTTP_DEPLOYMENT.md new file mode 100644 index 0000000..81ac489 --- /dev/null +++ b/docs/HTTP_DEPLOYMENT.md @@ -0,0 +1,118 @@ +# HTTP/SSE Deployment Guide + +## Overview + +The Late MCP server can be deployed via HTTP/SSE, allowing remote access from any MCP client. Each user provides their own Late API key when connecting. + +## Quick Start + +### Local Testing + +1. Install dependencies: +```bash +uv sync --extra mcp +``` + +2. Run HTTP server: +```bash +uv run late-mcp-http +``` + +3. Test the server: +```bash +# Health check (no auth needed) +curl http://localhost:8080/health + +# Server info (no auth needed) +curl http://localhost:8080/ + +# SSE endpoint (requires your Late API key) +curl -H "Authorization: Bearer your_late_api_key" http://localhost:8080/sse +``` + +## Railway Deployment + +### Using Dockerfile + +1. Push code to GitHub +2. Create new Railway project from repo +3. Railway auto-detects Dockerfile and deploys +4. No environment variables needed! (users provide their own API keys) + +### Environment Variables + +The server doesn't require any environment variables. Users authenticate by providing their Late API key when connecting. + +Optional variables: +- `HOST` (default: 0.0.0.0) +- `PORT` (default: 8080, Railway sets this automatically) + +## Connecting Clients + +### Claude Code CLI + +```bash +# Add the MCP server +claude mcp add --transport http late https://your-app.railway.app/sse + +# When connecting, provide your Late API key via header +# The Claude CLI will prompt for authentication details +``` + +Configuration in MCP settings: +```json +{ + "late": { + "url": "https://your-app.railway.app/sse", + "headers": { + "Authorization": "Bearer your_late_api_key_here" + } + } +} +``` + +### Python Client + +```python +from mcp.client.sse import sse_client + +# Provide your Late API key as Bearer token +headers = { + "Authorization": "Bearer your_late_api_key_here" +} + +async with sse_client( + "https://your-app.railway.app/sse", + headers=headers +) as (read, write): + # Use MCP client + pass +``` + +## Authentication + +Each user must provide their own Late API key when connecting using the standard HTTP Authorization header: + +``` +Authorization: Bearer YOUR_LATE_API_KEY +``` + +Example: +```bash +curl -H "Authorization: Bearer sk_your_api_key_here" \ + https://your-app.railway.app/sse +``` + +The server validates the API key by making a test request to the Late API. If valid, the connection is established and the API key is used for all subsequent operations. + +## Security + +- Each user's API key is validated against the Late API +- API keys are stored per-connection using Python's contextvars +- No shared credentials or server-wide API keys +- Health check endpoint is public (no auth required) +- All other endpoints require authentication + +## Get Your Late API Key + +Visit https://zernio.com to sign up and get your API key. diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..69e9766 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,93 @@ +# MCP Server (Claude Desktop Integration) + +Schedule posts directly from Claude Desktop using natural language with the Late MCP server. + +## Quick Setup + +### 1. Install uv (package manager) + +```bash +# macOS / Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Windows (PowerShell) +powershell -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +### 2. Add to Claude Desktop config + +Open the config file: +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Add this configuration: + +```json +{ + "mcpServers": { + "late": { + "command": "uvx", + "args": ["--from", "getlate[mcp]", "late-mcp"], + "env": { + "LATE_API_KEY": "your_api_key_here" + } + } + } +} +``` + +> Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys) + +### 3. Restart Claude Desktop + +Done! Ask Claude things like: +- *"Post 'Hello world!' to Twitter"* +- *"Schedule a LinkedIn post for tomorrow at 9am"* +- *"Show my connected accounts"* + +## Alternative: Using pip + +If you prefer pip over uvx: + +```bash +pip install getlate[mcp] +``` + +```json +{ + "mcpServers": { + "late": { + "command": "python", + "args": ["-m", "late.mcp"], + "env": { + "LATE_API_KEY": "your_api_key_here" + } + } + } +} +``` + +## Uploading Media + +Since Claude can't access local files, use the browser upload flow: + +1. Ask Claude: *"I want to post an image to Instagram"* +2. Claude gives you an upload link -> open it in your browser +3. Upload your file and tell Claude *"done"* +4. Claude creates the post with your media + +## Available Commands + +| Command | Description | +|---------|-------------| +| `accounts_list` | Show connected social accounts | +| `posts_create` | Create scheduled, immediate, or draft post | +| `posts_publish_now` | Publish immediately | +| `posts_cross_post` | Post to multiple platforms | +| `posts_list` | Show your posts | +| `posts_retry` | Retry a failed post | +| `media_generate_upload_link` | Get link to upload media | + +## Remote Access (HTTP/SSE) + +For remote deployment, see the [HTTP Deployment Guide](HTTP_DEPLOYMENT.md). diff --git a/examples/publish_post.py b/examples/publish_post.py index 017d796..d77ede6 100644 --- a/examples/publish_post.py +++ b/examples/publish_post.py @@ -17,7 +17,7 @@ def main() -> None: accounts = accounts_response.get("accounts", []) if not accounts: - print("No accounts connected. Connect an account at https://getlate.dev") + print("No accounts connected. Connect an account at https://zernio.com") return print(f"Found {len(accounts)} account(s):") @@ -32,7 +32,7 @@ def main() -> None: scheduled_time = datetime.now() + timedelta(hours=1) post = client.posts.create( - content="Hello from Late Python SDK! šŸš€", + content="Hello from Late Python SDK! \ud83d\ude80", platforms=[ { "platform": account["platform"], @@ -42,7 +42,7 @@ def main() -> None: scheduled_for=scheduled_time, ) - print(f"\nāœ… Post created!") + print(f"\n\u2705 Post created!") print(f" ID: {post['post']['_id']}") print(f" Status: {post['post']['status']}") print(f" Scheduled: {scheduled_time.strftime('%Y-%m-%d %H:%M')}") diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..b338749 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,13985 @@ +openapi: 3.1.0 +info: + title: Zernio API + version: "1.0.1" + description: | + API reference for Zernio. Authenticate with a Bearer API key. + Base URL: https://zernio.com/api + termsOfService: https://zernio.com/tos + contact: + name: Zernio Support + url: https://zernio.com + email: support@zernio.com + # RapidAPI extensions for Hub listing + x-logo: + url: https://zernio.com/icon.png?v=3 + x-long-description: | + Zernio is the social media API that replaces 14 integrations. Schedule posts, retrieve analytics, + manage DMs, comments, and reviews across Twitter/X, Instagram, TikTok, LinkedIn, Facebook, + YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business, and Snapchat, all + from a single REST API. + + Key features: Unified posting to 14 platforms, aggregated analytics, unified inbox (DMs, comments, reviews), webhooks, OAuth connect, queue scheduling, and white-label support for agencies managing 5,000+ accounts. + + Supported platforms: Twitter/X, Instagram, WhatsApp, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business, Telegram, Snapchat. + x-category: Social + x-website: https://zernio.com + x-thumbnail: https://rapidapi-prod-apis.s3.amazonaws.com/b24d3df5-563c-4a50-9e1e-1ad3eb1fce69.png + x-version-lifecycle: ACTIVE + x-badges: + - name: "social media" + value: "social media" + - name: "scheduling" + value: "scheduling" + - name: "instagram" + value: "instagram" + - name: "tiktok" + value: "tiktok" + - name: "twitter" + value: "twitter" + - name: "linkedin" + value: "linkedin" + - name: "facebook" + value: "facebook" + - name: "youtube" + value: "youtube" + - name: "social media api" + value: "social media api" + - name: "posting" + value: "posting" + +# RapidAPI Hub documentation tab (README) +x-documentation: + readme: | + # Zernio API + + The social media API that replaces 14 integrations. Build social media features into your app in minutes, not months. + + ## Quick Start + + **Base URL:** `https://zernio.com/api/v1` + + **Authentication:** All requests require a Bearer API key in the `Authorization` header. + + ```bash + curl https://zernio.com/api/v1/user \ + -H "Authorization: Bearer YOUR_API_KEY" + ``` + + Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys). + + ## Core Concepts + + | Concept | Description | + |---------|-------------| + | **Profiles** | Containers that organize social accounts into brands or projects | + | **Accounts** | Connected social media accounts belonging to a profile | + | **Posts** | Content scheduled or published to one or more accounts | + | **Queue** | Recurring time slots for automatic post scheduling | + + ## Create a Post + + ```bash + curl -X POST https://zernio.com/api/v1/post \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "profileId": "your-profile-id", + "text": "Hello from Zernio API!", + "socialAccountIds": ["account-1", "account-2"], + "scheduledAt": "2025-01-15T10:00:00Z" + }' + ``` + + This single call publishes or schedules the post to all selected accounts across any platform. + + ## Supported Platforms + + | Platform | Post | Stories/Reels | Analytics | Inbox | + |----------|------|---------------|-----------|-------| + | Twitter/X | Yes | - | Yes | Yes | + | Instagram | Yes | Yes | Yes | Yes | + | Facebook | Yes | Stories | Yes | Yes | + | LinkedIn | Yes | - | Yes | - | + | TikTok | Yes | - | Yes | - | + | YouTube | Yes | Shorts | Yes | Yes | + | Pinterest | Yes | - | Yes | - | + | Reddit | Yes | - | - | Yes | + | Bluesky | Yes | - | - | Yes | + | Threads | Yes | - | Yes | Yes | + | Google Business | Yes | - | - | Yes | + | Telegram | Yes | - | - | - | + | Snapchat | Yes | - | - | - | + + ## Rate Limits + + | Plan | Requests/min | Posts/month | + |------|-------------|-------------| + | Free | 60 | 20 | + | Build | 120 | 120 | + | Accelerate | 600 | Unlimited | + | Unlimited | 1,200 | Unlimited | + + All responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. + + ## Webhooks + + Receive real-time notifications for post status changes, account events, and incoming messages: + + - `post.scheduled` - Post successfully scheduled + - `post.published` - Post successfully published + - `post.failed` - Post failed on all platforms + - `post.partial` - Post published to some platforms, failed on others + - `post.recycled` - A new scheduled copy was created from a recycling post + - `account.connected` - Social account connected + - `account.disconnected` - Social account disconnected (token expired) + - `message.received` - New DM received + - `comment.received` - New comment received on a post + + Webhook payloads are signed with HMAC-SHA256 via the `X-Zernio-Signature` header. + + ## Full Documentation + + For complete guides, platform-specific details, and SDK references, visit [docs.zernio.com](https://docs.zernio.com). + + ## SDKs + + Official SDKs available for: [Node.js](https://www.npmjs.com/package/@zernio/node), [Python](https://pypi.org/project/zernio-sdk), Go, Ruby, Java, PHP, .NET, and Rust. + +servers: + - url: https://zernio.com/api + description: Production + - url: http://localhost:3000/api + description: Local +tags: + - name: Posts + - name: Users + - name: Usage + - name: Profiles + - name: Accounts + - name: Account Groups + - name: API Keys + - name: Invites + - name: Connect + - name: Media + # - name: Video Clips # AI Clipping feature temporarily disabled + - name: Reddit Search + - name: Facebook + - name: GMB Reviews + - name: GMB Food Menus + - name: GMB Location Details + - name: GMB Media + - name: GMB Attributes + - name: GMB Place Actions + - name: LinkedIn Mentions + - name: Pinterest + - name: TikTok + - name: Queue + - name: Analytics + - name: Inbox Access + description: | + Check and manage inbox feature access. + - name: Messages + description: | + Unified inbox API for managing conversations and direct messages across all connected accounts. + All endpoints aggregate data from multiple social accounts in a single API call. + Requires Inbox addon. + - name: Comments + description: | + Unified inbox API for managing comments on posts across all connected accounts. + Supports commenting on third-party posts for platforms that allow it (YouTube, Twitter, Reddit, Bluesky, Threads). + All endpoints aggregate data from multiple social accounts in a single API call. + Requires Inbox addon. + - name: Reviews + description: | + Unified inbox API for managing reviews on Facebook Pages and Google Business accounts. + All endpoints aggregate data from multiple social accounts in a single API call. + Requires Inbox addon. + - name: Twitter Engagement + description: | + X/Twitter-specific engagement endpoints for retweeting, bookmarking, and following. + Rate limits: 50 requests per 15-min window per user. Retweets share the 300/3hr creation limit with tweet creation. + - name: Tools + description: | + Media download and utility tools. Available to paid plans only. + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + All responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. + - name: Validate + description: | + Pre-flight validation endpoints. Check post content, character limits, media URLs, and subreddit existence before publishing. + - name: Account Settings + description: | + Platform-specific account settings: Facebook persistent menu, Instagram ice breakers, and Telegram bot commands. + - name: Webhooks + description: | + Configure webhooks for real-time notifications. Events: post.scheduled, post.published, post.failed, post.partial, post.recycled, account.connected, account.disconnected, message.received, comment.received. + Security: optional HMAC-SHA256 signature in X-Zernio-Signature header. Configure a secret key to enable verification. Custom headers supported. + - name: Logs + description: | + Publishing logs for transparency and debugging. Each log includes the platform API endpoint, HTTP status code, request/response bodies, duration, and retry attempts. Logs are automatically deleted after 7 days. + - name: WhatsApp + description: | + WhatsApp Business API for sending messages, managing contacts, templates, broadcasts, and conversations. + All endpoints require an accountId parameter identifying the WhatsApp-connected social account. + - name: WhatsApp Phone Numbers + description: | + Manage WhatsApp phone numbers: purchase, verify, and release numbers for your WhatsApp Business account. + Requires a paid plan. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: API key authentication - use your Zernio API key as a Bearer token + connectToken: + type: apiKey + in: header + name: X-Connect-Token + description: | + Short-lived connect token for API users during OAuth flows. + Automatically generated when initiating OAuth without a browser session. + Valid for 15 minutes. Used to authenticate Facebook page selection API calls. + parameters: + PageParam: + name: page + in: query + description: Page number (1-based) + schema: { type: integer, minimum: 1, default: 1 } + LimitParam: + name: limit + in: query + description: Page size + schema: { type: integer, minimum: 1, maximum: 100, default: 10 } + responses: + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Unauthorized + NotFound: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Not found + schemas: + ErrorResponse: + type: object + properties: + error: + type: string + details: + type: object + additionalProperties: true + FoodMenuLabel: + type: object + required: [displayName] + properties: + displayName: { type: string, description: Display name of the item/section/menu } + description: { type: string, description: Optional description } + languageCode: { type: string, description: "BCP-47 language code (e.g. en, es)" } + Money: + type: object + required: [currencyCode, units] + properties: + currencyCode: { type: string, description: "ISO 4217 currency code (e.g. USD, EUR)" } + units: { type: string, description: Whole units of the amount } + nanos: { type: integer, description: Nano units (10^-9) of the amount } + FoodMenuItemAttributes: + type: object + properties: + price: { $ref: '#/components/schemas/Money' } + spiciness: { type: string, description: "Spiciness level (e.g. MILD, MEDIUM, HOT)" } + allergen: + type: array + items: { type: string } + description: "Allergens (e.g. DAIRY, GLUTEN, SHELLFISH)" + dietaryRestriction: + type: array + items: { type: string } + description: "Dietary labels (e.g. VEGETARIAN, VEGAN, GLUTEN_FREE)" + servesNumPeople: { type: integer, description: Number of people the item serves } + preparationMethods: + type: array + items: { type: string } + description: "Preparation methods (e.g. GRILLED, FRIED)" + mediaKeys: + type: array + items: { type: string } + description: Media references for item photos + FoodMenuItem: + type: object + required: [labels] + properties: + labels: + type: array + items: { $ref: '#/components/schemas/FoodMenuLabel' } + attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' } + options: + type: array + items: + type: object + properties: + labels: + type: array + items: { $ref: '#/components/schemas/FoodMenuLabel' } + attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' } + description: Item variants/options (e.g. sizes, preparations) + FoodMenuSection: + type: object + required: [labels] + properties: + labels: + type: array + items: { $ref: '#/components/schemas/FoodMenuLabel' } + items: + type: array + items: { $ref: '#/components/schemas/FoodMenuItem' } + FoodMenu: + type: object + required: [labels] + properties: + labels: + type: array + items: { $ref: '#/components/schemas/FoodMenuLabel' } + sections: + type: array + items: { $ref: '#/components/schemas/FoodMenuSection' } + cuisines: + type: array + items: { type: string } + description: "Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE)" + sourceUrl: + type: string + description: URL of the original menu source + YouTubeDailyViewsResponse: + type: object + properties: + success: + type: boolean + example: true + videoId: + type: string + description: The YouTube video ID + dateRange: + type: object + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + totalViews: + type: integer + description: Sum of views across all days in the range + dailyViews: + type: array + items: + type: object + properties: + date: + type: string + format: date + views: + type: integer + estimatedMinutesWatched: + type: number + averageViewDuration: + type: number + description: Average view duration in seconds + subscribersGained: + type: integer + subscribersLost: + type: integer + likes: + type: integer + comments: + type: integer + shares: + type: integer + lastSyncedAt: + type: string + format: date-time + nullable: true + description: When the data was last synced from YouTube + scopeStatus: + type: object + properties: + hasAnalyticsScope: + type: boolean + YouTubeScopeMissingResponse: + type: object + properties: + success: + type: boolean + example: false + error: + type: string + example: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions." + code: + type: string + example: youtube_analytics_scope_missing + scopeStatus: + type: object + properties: + hasAnalyticsScope: + type: boolean + example: false + requiresReauthorization: + type: boolean + example: true + reauthorizeUrl: + type: string + format: uri + description: URL to redirect user for reauthorization + Webhook: + type: object + description: Individual webhook configuration for receiving real-time notifications + properties: + _id: + type: string + description: Unique webhook identifier + name: + type: string + description: Webhook name (for identification) + maxLength: 50 + url: + type: string + format: uri + description: Webhook endpoint URL + secret: + type: string + description: Secret key for HMAC-SHA256 signature (not returned in responses for security) + events: + type: array + items: + type: string + enum: [post.scheduled, post.published, post.failed, post.partial, post.recycled, account.connected, account.disconnected, message.received, comment.received] + description: Events subscribed to + isActive: + type: boolean + description: Whether webhook delivery is enabled + lastFiredAt: + type: string + format: date-time + description: Timestamp of last successful webhook delivery + failureCount: + type: integer + description: Consecutive delivery failures (resets on success, webhook disabled at 10) + customHeaders: + type: object + additionalProperties: + type: string + description: Custom headers included in webhook requests + WebhookLog: + type: object + description: Webhook delivery log entry + properties: + _id: + type: string + webhookId: + type: string + description: ID of the webhook that was triggered + webhookName: + type: string + description: Name of the webhook that was triggered + event: + type: string + enum: [post.scheduled, post.published, post.failed, post.partial, post.recycled, account.connected, account.disconnected, message.received, comment.received, webhook.test] + url: + type: string + format: uri + status: + type: string + enum: [success, failed] + statusCode: + type: integer + description: HTTP status code from webhook endpoint + requestPayload: + type: object + description: Payload sent to webhook endpoint + responseBody: + type: string + description: Response body from webhook endpoint (truncated to 10KB) + errorMessage: + type: string + description: Error message if delivery failed + attemptNumber: + type: integer + description: Delivery attempt number (max 3 retries) + responseTime: + type: integer + description: Response time in milliseconds + createdAt: + type: string + format: date-time + WebhookPayloadPost: + type: object + description: Webhook payload for post events + properties: + event: + type: string + enum: [post.scheduled, post.published, post.failed, post.partial, post.recycled] + post: + type: object + properties: + id: + type: string + content: + type: string + status: + type: string + scheduledFor: + type: string + format: date-time + publishedAt: + type: string + format: date-time + platforms: + type: array + items: + type: object + properties: + platform: + type: string + status: + type: string + publishedUrl: + type: string + error: + type: string + timestamp: + type: string + format: date-time + WebhookPayloadAccountConnected: + type: object + description: Webhook payload for account connected events + properties: + event: + type: string + enum: [account.connected] + account: + type: object + properties: + accountId: + type: string + description: The account's unique identifier (same as used in /v1/accounts/{accountId}) + profileId: + type: string + description: The profile's unique identifier this account belongs to + platform: + type: string + username: + type: string + displayName: + type: string + timestamp: + type: string + format: date-time + WebhookPayloadAccountDisconnected: + type: object + description: Webhook payload for account disconnected events + properties: + event: + type: string + enum: [account.disconnected] + account: + type: object + properties: + accountId: + type: string + description: The account's unique identifier (same as used in /v1/accounts/{accountId}) + profileId: + type: string + description: The profile's unique identifier this account belongs to + platform: + type: string + username: + type: string + displayName: + type: string + disconnectionType: + type: string + enum: [intentional, unintentional] + description: Whether the disconnection was intentional (user action) or unintentional (token expired/revoked) + reason: + type: string + description: Human-readable reason for the disconnection + timestamp: + type: string + format: date-time + WebhookPayloadComment: + type: object + description: Webhook payload for comment received events (Instagram, Facebook, Twitter/X, YouTube, LinkedIn, Bluesky, Reddit) + properties: + event: + type: string + enum: [comment.received] + comment: + type: object + properties: + id: + type: string + description: Platform comment ID + postId: + type: string + description: Internal post ID + platformPostId: + type: string + description: Platform's post ID + platform: + type: string + enum: [instagram, facebook, twitter, youtube, linkedin, bluesky, reddit] + text: + type: string + description: Comment text content + author: + type: object + properties: + id: + type: string + description: Author's platform ID + username: + type: string + name: + type: string + picture: + type: string + nullable: true + createdAt: + type: string + format: date-time + isReply: + type: boolean + description: Whether this is a reply to another comment + parentCommentId: + type: string + nullable: true + description: Parent comment ID if this is a reply + post: + type: object + properties: + id: + type: string + description: Internal post ID + platformPostId: + type: string + description: Platform's post ID + account: + type: object + properties: + id: + type: string + description: Social account ID + platform: + type: string + username: + type: string + timestamp: + type: string + format: date-time + WebhookPayloadMessage: + type: object + description: Webhook payload for message received events (DMs from Instagram, Facebook, Telegram, Bluesky, Reddit) + properties: + event: + type: string + enum: [message.received] + message: + type: object + properties: + id: + type: string + description: Internal message ID + conversationId: + type: string + description: Internal conversation ID + platform: + type: string + enum: [instagram, facebook, telegram, bluesky, reddit] + platformMessageId: + type: string + description: Platform's message ID + direction: + type: string + enum: [incoming] + text: + type: string + nullable: true + description: Message text content + attachments: + type: array + items: + type: object + properties: + type: + type: string + description: Attachment type (image, video, file, sticker, audio) + url: + type: string + description: Attachment URL (may expire for Meta platforms) + payload: + type: object + description: Additional attachment metadata + sender: + type: object + properties: + id: + type: string + name: + type: string + username: + type: string + picture: + type: string + instagramProfile: + type: object + nullable: true + description: Instagram profile data for the sender. Only present for Instagram conversations. + properties: + isFollower: + type: boolean + nullable: true + description: Whether the sender follows your Instagram business account + isFollowing: + type: boolean + nullable: true + description: Whether your Instagram business account follows the sender + followerCount: + type: integer + nullable: true + description: The sender's follower count on Instagram + isVerified: + type: boolean + nullable: true + description: Whether the sender is a verified Instagram user + sentAt: + type: string + format: date-time + isRead: + type: boolean + conversation: + type: object + properties: + id: + type: string + platformConversationId: + type: string + participantId: + type: string + participantName: + type: string + participantUsername: + type: string + participantPicture: + type: string + status: + type: string + enum: [active, archived] + account: + type: object + properties: + id: + type: string + description: Social account ID + platform: + type: string + username: + type: string + displayName: + type: string + metadata: + type: object + nullable: true + description: Interactive message metadata (present when message is a quick reply tap, postback button tap, or inline keyboard callback) + properties: + quickReplyPayload: + type: string + description: Payload from a quick reply tap (Meta platforms) + postbackPayload: + type: string + description: Payload from a postback button tap (Meta platforms) + postbackTitle: + type: string + description: Title of the tapped postback button (Meta platforms) + callbackData: + type: string + description: Callback data from an inline keyboard button tap (Telegram) + timestamp: + type: string + format: date-time + PostLog: + type: object + description: Publishing log entry showing details of a post publishing attempt + properties: + _id: + type: string + postId: + oneOf: + - type: string + - type: object + description: Populated post reference + properties: + _id: + type: string + content: + type: string + status: + type: string + userId: + type: string + profileId: + type: string + platform: + type: string + enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat] + accountId: + type: string + accountUsername: + type: string + action: + type: string + enum: [publish, retry, media_upload, rate_limit_pause, token_refresh, cancelled] + description: "Type of action logged: publish (initial attempt), retry (after failure), media_upload, rate_limit_pause, token_refresh, cancelled" + status: + type: string + enum: [success, failed, pending, skipped] + statusCode: + type: integer + description: HTTP status code from platform API + endpoint: + type: string + description: Platform API endpoint called + request: + type: object + properties: + contentPreview: + type: string + description: First 200 chars of caption + mediaCount: + type: integer + mediaTypes: + type: array + items: + type: string + mediaUrls: + type: array + items: + type: string + description: URLs of media items sent to platform + scheduledFor: + type: string + format: date-time + rawBody: + type: string + description: Full request body JSON (max 5000 chars) + response: + type: object + properties: + platformPostId: + type: string + description: ID returned by platform on success + platformPostUrl: + type: string + description: URL of published post + errorMessage: + type: string + description: Error message on failure + errorCode: + type: string + description: Platform-specific error code + rawBody: + type: string + description: Full response body JSON (max 5000 chars) + durationMs: + type: integer + description: How long the operation took in milliseconds + attemptNumber: + type: integer + description: Attempt number (1 for first try, 2+ for retries) + createdAt: + type: string + format: date-time + ConnectionLog: + type: object + description: Connection event log showing account connection/disconnection history + properties: + _id: + type: string + userId: + type: string + description: User who owns the connection (may be null for early OAuth failures) + profileId: + type: string + accountId: + type: string + description: The social account ID (present on successful connections and disconnects) + platform: + type: string + enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat] + eventType: + type: string + enum: [connect_success, connect_failed, disconnect, reconnect_success, reconnect_failed] + description: "Type of connection event: connect_success, connect_failed, disconnect, reconnect_success, reconnect_failed" + connectionMethod: + type: string + enum: [oauth, credentials, invitation] + description: How the connection was initiated + error: + type: object + description: Error details (present on failed events) + properties: + code: + type: string + description: Error code (e.g., oauth_denied, token_exchange_failed) + message: + type: string + description: Human-readable error message + rawResponse: + type: string + description: Raw error response (truncated to 2000 chars) + success: + type: object + description: Success details (present on successful events) + properties: + displayName: + type: string + username: + type: string + profilePicture: + type: string + permissions: + type: array + items: + type: string + description: OAuth scopes/permissions granted + tokenExpiresAt: + type: string + format: date-time + accountType: + type: string + description: Account type (personal, business, organization) + context: + type: object + description: Additional context about the connection attempt + properties: + isHeadlessMode: + type: boolean + hasCustomRedirectUrl: + type: boolean + isReconnection: + type: boolean + isBYOK: + type: boolean + description: Using bring-your-own-keys + invitationToken: + type: string + connectToken: + type: string + durationMs: + type: integer + description: How long the operation took in milliseconds + metadata: + type: object + description: Additional metadata + createdAt: + type: string + format: date-time + MediaItem: + type: object + description: Media referenced in posts. URLs must be publicly reachable over HTTPS. Use POST /v1/media/presign for uploads up to 5GB. Zernio auto-compresses images and videos that exceed platform limits (videos over 200 MB may not be compressed). + properties: + type: + type: string + enum: [image, video, gif, document] + url: + type: string + format: uri + title: + type: string + description: Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename. + filename: + type: string + size: + type: integer + description: Optional file size in bytes + mimeType: + type: string + description: Optional MIME type (e.g. image/jpeg, video/mp4) + thumbnail: + type: string + format: uri + description: Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, and regular video uploads. Max 10MB, JPG/PNG recommended. + instagramThumbnail: + type: string + format: uri + description: Optional custom cover image URL for Instagram Reels + tiktokProcessed: + type: boolean + description: Internal flag indicating the image was resized for TikTok + PlatformTarget: + type: object + properties: + platform: + type: string + example: twitter + description: "Supported values: twitter, threads, instagram, youtube, facebook, linkedin, pinterest, reddit, tiktok, bluesky, googlebusiness, telegram" + accountId: + oneOf: + - type: string + - $ref: '#/components/schemas/SocialAccount' + customContent: + type: string + description: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters). + customMedia: + type: array + items: + $ref: '#/components/schemas/MediaItem' + scheduledFor: + type: string + format: date-time + description: Optional per-platform scheduled time override (uses post.scheduledFor when omitted) + platformSpecificData: + description: Platform-specific overrides and options. + oneOf: + - $ref: '#/components/schemas/TwitterPlatformData' + - $ref: '#/components/schemas/ThreadsPlatformData' + - $ref: '#/components/schemas/FacebookPlatformData' + - $ref: '#/components/schemas/InstagramPlatformData' + - $ref: '#/components/schemas/LinkedInPlatformData' + - $ref: '#/components/schemas/PinterestPlatformData' + - $ref: '#/components/schemas/YouTubePlatformData' + - $ref: '#/components/schemas/GoogleBusinessPlatformData' + - $ref: '#/components/schemas/TikTokPlatformData' + - $ref: '#/components/schemas/TelegramPlatformData' + - $ref: '#/components/schemas/SnapchatPlatformData' + - $ref: '#/components/schemas/RedditPlatformData' + - $ref: '#/components/schemas/BlueskyPlatformData' + additionalProperties: true + status: + type: string + example: pending + description: "Platform-specific status: pending, publishing, published, failed" + platformPostId: + type: string + description: The native post ID on the platform (populated after successful publish) + example: "1234567890123456789" + platformPostUrl: + type: string + format: uri + description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. + example: "https://twitter.com/acmecorp/status/1234567890123456789" + publishedAt: + type: string + format: date-time + description: Timestamp when the post was published to this platform + errorMessage: + type: string + description: Human-readable error message when status is failed. Contains platform-specific error details explaining why the publish failed. + errorCategory: + type: string + enum: [auth_expired, user_content, user_abuse, account_issue, platform_rejected, platform_error, system_error, unknown] + description: "Error category for programmatic handling: auth_expired (token expired/revoked), user_content (wrong format/too long), user_abuse (rate limits/spam), account_issue (config problems), platform_rejected (policy violation), platform_error (5xx/maintenance), system_error (Zernio infra), unknown" + errorSource: + type: string + enum: [user, platform, system] + description: "Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare)" + Post: + type: object + properties: + _id: { type: string } + userId: + oneOf: + - type: string + - $ref: '#/components/schemas/User' + title: + type: string + description: | + YouTube: title must be ≤ 100 characters. + content: { type: string } + mediaItems: + type: array + items: { $ref: '#/components/schemas/MediaItem' } + platforms: + type: array + items: { $ref: '#/components/schemas/PlatformTarget' } + scheduledFor: { type: string, format: date-time } + timezone: { type: string } + status: { type: string, enum: [draft, scheduled, publishing, published, failed, partial] } + tags: + type: array + description: "YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed." + items: { type: string } + hashtags: + type: array + items: { type: string } + mentions: + type: array + items: { type: string } + visibility: { type: string, enum: [public, private, unlisted] } + metadata: + type: object + additionalProperties: true + recycling: + $ref: '#/components/schemas/RecyclingState' + recycledFromPostId: + type: string + description: ID of the original post if this post was created via recycling + queuedFromProfile: + type: string + description: Profile ID if the post was scheduled via the queue + queueId: + type: string + description: Queue ID if the post was scheduled via a specific queue + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + RecyclingConfig: + type: object + description: | + Configure automatic post recycling (reposting at regular intervals). + After the post is published, the system creates new scheduled copies at the + specified interval until expiration conditions are met. Supports weekly or + monthly intervals. Maximum 10 active recycling posts per account. + YouTube and TikTok platforms are excluded from recycling. + Content variations are recommended for Twitter and Pinterest to avoid duplicate flags. + properties: + enabled: + type: boolean + default: true + description: Set to false to disable recycling on this post + gap: + type: integer + minimum: 1 + description: Number of interval units between each repost. Required when enabling recycling. + example: 2 + gapFreq: + type: string + enum: [week, month] + default: month + description: Interval unit for the gap. Defaults to 'month'. + startDate: + type: string + format: date-time + description: When to start the recycling cycle. Defaults to the post's scheduledFor date. + expireCount: + type: integer + minimum: 1 + description: Stop recycling after this many copies have been created + example: 5 + expireDate: + type: string + format: date-time + description: Stop recycling after this date, regardless of count + contentVariations: + type: array + items: + type: string + maxItems: 20 + description: | + Array of content variations for recycled copies. On each recycle, the next + variation is used in round-robin order. Recommended for Twitter and Pinterest + to avoid duplicate content flags. If omitted, the original post content is + used for all recycled copies. Send an empty array [] to clear existing + variations. Must have 2+ entries when setting variations. Platform-level + customContent still overrides the base content per platform. + RecyclingState: + type: object + description: Current recycling configuration and state on a post + properties: + enabled: + type: boolean + description: Whether recycling is currently active + gap: + type: integer + description: Number of interval units between reposts + gapFreq: + type: string + enum: [week, month] + description: Interval unit (week or month) + startDate: { type: string, format: date-time } + expireCount: { type: integer } + expireDate: { type: string, format: date-time } + contentVariations: + type: array + items: + type: string + description: Content variations for recycled copies (if configured) + contentVariationIndex: + type: integer + description: Current position in the content variations rotation (read-only) + recycleCount: + type: integer + description: How many recycled copies have been created so far (read-only) + nextRecycleAt: + type: string + format: date-time + description: When the next recycled copy will be created (read-only) + lastRecycledAt: + type: string + format: date-time + description: When the last recycled copy was created (read-only) + + TwitterPlatformData: + type: object + properties: + replyToTweetId: + type: string + description: ID of an existing tweet to reply to. The published tweet will appear as a reply in that tweet's thread. For threads, only the first tweet replies to the target; subsequent tweets chain normally. + replySettings: + type: string + enum: [following, mentionedUsers, subscribers, verified] + description: Controls who can reply to the tweet. "following" allows only people you follow, "mentionedUsers" allows only mentioned users, "subscribers" allows only subscribers, "verified" allows only verified users. Omit for default (everyone can reply). For threads, applies to the first tweet only. Cannot be combined with replyToTweetId. + threadItems: + type: array + description: Sequence of tweets in a thread. First item is the root tweet. + items: + type: object + properties: + content: { type: string } + mediaItems: + type: array + items: { $ref: '#/components/schemas/MediaItem' } + + ThreadsPlatformData: + type: object + properties: + threadItems: + type: array + description: Sequence of posts in a Threads thread (root then replies in order). + items: + type: object + properties: + content: { type: string } + mediaItems: + type: array + items: { $ref: '#/components/schemas/MediaItem' } + description: Up to 10 images per carousel (no videos). Videos must be H.264/AAC MP4, max 5 min. Images JPEG/PNG, max 8 MB. Use threadItems for reply chains. + + FacebookPlatformData: + type: object + properties: + contentType: + type: string + enum: [story, reel] + description: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. + title: + type: string + description: Reel title (only for contentType=reel). Separate from the caption/content field. + firstComment: + type: string + description: Optional first comment to post immediately after publishing (feed posts only, not stories or reels) + pageId: + type: string + description: Target Facebook Page ID for multi-page posting. If omitted, uses the default page. Use GET /v1/accounts/{id}/facebook-page to list pages. + description: Feed posts support up to 10 images (no mixed video+image). Stories require single media (24h, no captions). Reels require single vertical video (9:16, 3-60s). + + InstagramPlatformData: + type: object + properties: + contentType: + type: string + enum: [story] + description: Set to 'story' to publish as a Story. Default posts become Reels or feed depending on media. + shareToFeed: + type: boolean + default: true + description: For Reels only. When true (default), the Reel appears on both the Reels tab and your main profile feed. Set to false to post to the Reels tab only. + collaborators: + type: array + items: { type: string } + description: Up to 3 Instagram usernames to invite as collaborators (feed/Reels only) + firstComment: + type: string + description: Optional first comment to add after the post is created (not applied to Stories) + trialParams: + type: object + description: Trial Reels configuration. Trial reels are shared to non-followers first and can later be graduated to regular reels manually or automatically based on performance. Only applies to Reels. + properties: + graduationStrategy: + type: string + enum: [MANUAL, SS_PERFORMANCE] + description: "MANUAL (graduate from Instagram app) or SS_PERFORMANCE (auto-graduate if performs well with non-followers)" + userTags: + type: array + description: Tag Instagram users in photos by username and position. Not supported for stories or videos. For carousels, use mediaIndex to target specific slides (defaults to 0). Tags on video items are silently skipped. + items: + type: object + required: [username, x, y] + properties: + username: + type: string + description: Instagram username (@ symbol is optional and will be removed automatically) + example: friend_username + x: + type: number + minimum: 0 + maximum: 1 + description: X coordinate position from left edge (0.0 = left, 0.5 = center, 1.0 = right) + example: 0.5 + y: + type: number + minimum: 0 + maximum: 1 + description: Y coordinate position from top edge (0.0 = top, 0.5 = center, 1.0 = bottom) + example: 0.5 + mediaIndex: + type: integer + minimum: 0 + description: Zero-based index of the carousel item to tag. Defaults to 0. Tags on video items or out-of-range indices are ignored. + example: 0 + audioName: + type: string + description: Custom name for original audio in Reels. Replaces the default "Original Audio" label. Can only be set once. + example: "My Podcast Intro" + thumbOffset: + type: integer + minimum: 0 + description: Millisecond offset from video start for the Reel thumbnail. Ignored if a custom thumbnail URL is provided. Defaults to 0. + example: 5000 + description: Feed aspect ratio 0.8-1.91, carousels up to 10 items, stories require media (no captions). User tag coordinates 0.0-1.0 from top-left. Images over 8 MB and videos over platform limits are auto-compressed. + + LinkedInPlatformData: + type: object + properties: + documentTitle: + type: string + description: Title displayed on LinkedIn document (PDF/carousel) posts. Required by LinkedIn for document posts. If omitted, falls back to the media item title, then the filename. + organizationUrn: + type: string + description: Target LinkedIn Organization URN (e.g. "urn:li:organization:123456789"). If omitted, uses the default org. Use GET /v1/accounts/{id}/linkedin-organizations to list orgs. + firstComment: + type: string + description: Optional first comment to add after the post is created + disableLinkPreview: + type: boolean + description: Set to true to disable automatic link previews for URLs in the post content (default is false) + description: Up to 20 images, no multi-video. Single PDF supported (max 100MB). Link previews auto-generated when no media attached. Use organizationUrn for multi-org posting. + + PinterestPlatformData: + type: object + properties: + title: + type: string + maxLength: 100 + description: Pin title. Defaults to first line of content or "Pin". Must be ≤ 100 characters. + boardId: + type: string + description: Target Pinterest board ID. If omitted, the first available board is used. + link: + type: string + format: uri + description: Destination link (pin URL) + coverImageUrl: + type: string + format: uri + description: Optional cover image for video pins + coverImageKeyFrameTime: + type: integer + description: Optional key frame time in seconds for derived video cover + + YouTubePlatformData: + type: object + properties: + title: + type: string + maxLength: 100 + description: Video title. Defaults to first line of content or "Untitled Video". Must be ≤ 100 characters. + visibility: + type: string + enum: [public, private, unlisted] + default: public + description: "Video visibility: public (default, anyone can watch), unlisted (link only), private (invite only)" + madeForKids: + type: boolean + default: false + description: COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting). Defaults to false. YouTube may block views if not explicitly set. + firstComment: + type: string + maxLength: 10000 + description: Optional first comment to post immediately after video upload. Up to 10,000 characters (YouTube's comment limit). + containsSyntheticMedia: + type: boolean + default: false + description: AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label. + categoryId: + type: string + default: '22' + description: "YouTube video category ID. Defaults to 22 (People & Blogs). Common: 1 (Film), 2 (Autos), 10 (Music), 15 (Pets), 17 (Sports), 20 (Gaming), 23 (Comedy), 24 (Entertainment), 25 (News), 26 (Howto), 27 (Education), 28 (Science & Tech)." + description: Videos under 3 min auto-detected as Shorts. Custom thumbnails for regular videos only. Scheduled videos are uploaded immediately with the specified visibility. + + GoogleBusinessPlatformData: + type: object + properties: + locationId: + type: string + description: Target GBP location ID (e.g. "locations/123456789"). If omitted, uses the default location. Use GET /v1/accounts/{id}/gmb-locations to list locations. + languageCode: + type: string + description: BCP 47 language code (e.g. "en", "de", "es"). Auto-detected if omitted. Set explicitly for short or mixed-language posts. + example: "de" + callToAction: + type: object + description: Optional call-to-action button displayed on the post + properties: + type: + type: string + enum: [LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL] + description: "Button action type: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL" + url: + type: string + format: uri + description: Destination URL for the CTA button (required when callToAction is provided) + required: [type, url] + description: Text and single image only (no videos). Optional call-to-action button. Posts appear on GBP, Google Search, and Maps. Use locationId for multi-location posting. + + TikTokPlatformData: + type: object + description: Photo carousels up to 35 images. Video titles up to 2200 chars, photo titles truncated to 90 chars. privacyLevel must match creator_info options. Both camelCase and snake_case accepted. + properties: + draft: + type: boolean + description: When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing immediately. + privacyLevel: + type: string + description: One of the values returned by the TikTok creator info API for the account + allowComment: + type: boolean + description: Allow comments on the post + allowDuet: + type: boolean + description: Allow duets (required for video posts) + allowStitch: + type: boolean + description: Allow stitches (required for video posts) + commercialContentType: + type: string + enum: [none, brand_organic, brand_content] + description: Type of commercial content disclosure + brandPartnerPromote: + type: boolean + description: Whether the post promotes a brand partner + isBrandOrganicPost: + type: boolean + description: Whether the post is a brand organic post + contentPreviewConfirmed: + type: boolean + description: User has confirmed they previewed the content + expressConsentGiven: + type: boolean + description: User has given express consent for posting + mediaType: + type: string + enum: [video, photo] + description: Optional override. Defaults based on provided media items. + videoCoverTimestampMs: + type: integer + description: Optional for video posts. Timestamp in milliseconds to select which frame to use as thumbnail (defaults to 1000ms/1 second). + minimum: 0 + photoCoverIndex: + type: integer + description: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). + minimum: 0 + autoAddMusic: + type: boolean + description: When true, TikTok may add recommended music (photos only) + videoMadeWithAi: + type: boolean + description: Set true to disclose AI-generated content + description: + type: string + maxLength: 4000 + description: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. + + TelegramPlatformData: + type: object + properties: + parseMode: + type: string + enum: [HTML, Markdown, MarkdownV2] + description: Text formatting mode for the message (default is HTML) + disableWebPagePreview: + type: boolean + description: Disable link preview generation for URLs in the message + disableNotification: + type: boolean + description: Send the message silently (users will receive notification without sound) + protectContent: + type: boolean + description: Protect message content from forwarding and saving + description: Text, images (up to 10), videos (up to 10), and mixed media albums. Captions up to 1024 chars for media, 4096 for text-only. + + SnapchatPlatformData: + type: object + properties: + contentType: + type: string + enum: [story, saved_story, spotlight] + default: story + description: "Content type: story (ephemeral 24h, default), saved_story (permanent on Public Profile), spotlight (video feed)" + description: "Requires a Public Profile. Single media item only. Content types: story (ephemeral 24h), saved_story (permanent, title max 45 chars), spotlight (video, max 160 chars)." + + RedditPlatformData: + type: object + properties: + subreddit: + type: string + description: Target subreddit name (without "r/" prefix). Overrides the default. Use GET /v1/accounts/{id}/reddit-subreddits to list options. + example: socialmedia + title: + type: string + maxLength: 300 + description: Post title. Defaults to the first line of content, truncated to 300 characters. + url: + type: string + format: uri + description: URL for link posts. If provided (and forceSelf is not true), creates a link post instead of a text post. + forceSelf: + type: boolean + description: When true, creates a text/self post even when a URL or media is provided. + flairId: + type: string + description: Flair ID for the post. Required by some subreddits. Use GET /v1/accounts/{id}/reddit-flairs?subreddit=name to list flairs. + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + description: Posts are either link (with URL/media) or self (text-only). Use forceSelf to override. Subreddit defaults to the account's configured one. Some subreddits require a flair. + + BlueskyPlatformData: + type: object + properties: + threadItems: + type: array + description: Sequence of posts in a Bluesky thread (root then replies in order). + items: + type: object + properties: + content: + type: string + mediaItems: + type: array + items: + $ref: '#/components/schemas/MediaItem' + description: | + Bluesky post settings. Supports text posts with up to 4 images or a single video. threadItems creates a reply chain (Bluesky thread). Images exceeding 1MB are automatically compressed. Alt text supported via mediaItem properties. + + QueueSlot: + type: object + properties: + dayOfWeek: + type: integer + description: Day of week (0=Sunday, 6=Saturday) + minimum: 0 + maximum: 6 + time: + type: string + description: Time in HH:mm format (24-hour) + pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$' + QueueSchedule: + type: object + properties: + _id: + type: string + description: Unique queue identifier + profileId: + type: string + description: Profile ID this queue belongs to + name: + type: string + description: Queue name (e.g., "Morning Posts", "Evening Content") + timezone: + type: string + description: IANA timezone (e.g., America/New_York) + slots: + type: array + items: + $ref: '#/components/schemas/QueueSlot' + active: + type: boolean + description: Whether the queue is active + isDefault: + type: boolean + description: Whether this is the default queue for the profile (used when no queueId specified) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + Pagination: + type: object + properties: + page: { type: integer } + limit: { type: integer } + total: { type: integer } + pages: { type: integer } + Profile: + type: object + properties: + _id: { type: string } + userId: { type: string } + name: { type: string } + description: { type: string } + color: { type: string } + isDefault: { type: boolean } + isOverLimit: + type: boolean + description: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. + createdAt: { type: string, format: date-time } + SocialAccount: + type: object + properties: + _id: { type: string } + platform: { type: string } + profileId: + oneOf: + - type: string + - $ref: '#/components/schemas/Profile' + username: { type: string } + displayName: { type: string } + profileUrl: + type: string + description: Full profile URL for the connected account on its platform. + isActive: { type: boolean } + followersCount: + type: number + description: Follower count (only included if user has analytics add-on) + followersLastUpdated: + type: string + format: date-time + description: Last time follower count was updated (only included if user has analytics add-on) + AccountWithFollowerStats: + allOf: + - $ref: '#/components/schemas/SocialAccount' + - type: object + properties: + profilePicture: { type: string } + currentFollowers: { type: number, description: Current follower count } + lastUpdated: { type: string, format: date-time } + growth: { type: number, description: Follower change over period } + growthPercentage: { type: number, description: Percentage growth } + dataPoints: { type: number, description: Number of historical snapshots } + accountStats: + type: object + description: | + Platform-specific account stats from the latest daily snapshot. + Fields vary by platform. Only present if metadata has been captured. + properties: + followingCount: { type: number, description: Number of accounts being followed } + mediaCount: { type: number, description: Total media posts (Instagram) } + videoCount: { type: number, description: Total videos (YouTube, TikTok) } + tweetCount: { type: number, description: Total tweets (X/Twitter) } + postsCount: { type: number, description: Total posts (Bluesky) } + pinCount: { type: number, description: Total pins (Pinterest) } + totalViews: { type: number, description: Total channel views (YouTube) } + likesCount: { type: number, description: Total likes received (TikTok) } + monthlyViews: { type: number, description: Monthly profile views (Pinterest) } + listedCount: { type: number, description: Lists the user appears on (X/Twitter) } + boardCount: { type: number, description: Total boards (Pinterest) } + ApiKey: + type: object + properties: + id: { type: string } + name: { type: string } + keyPreview: { type: string } + expiresAt: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + key: + type: string + description: Returned only once, on creation + scope: + type: string + enum: [full, profiles] + description: "'full' grants access to all profiles, 'profiles' restricts to specific profiles" + default: full + profileIds: + type: array + items: + type: object + properties: + _id: { type: string } + name: { type: string } + color: { type: string } + description: Profiles this key can access (populated with name and color). Only present when scope is 'profiles'. + permission: + type: string + enum: [read-write, read] + description: "'read-write' allows all operations, 'read' restricts to GET requests only" + default: read-write + UsageStats: + type: object + properties: + planName: { type: string } + billingPeriod: { type: string, enum: [monthly, yearly] } + signupDate: { type: string, format: date-time } + billingAnchorDay: { type: integer, description: "Day of month (1-31) when the billing cycle resets" } + limits: + type: object + properties: + uploads: { type: integer } + profiles: { type: integer } + usage: + type: object + properties: + uploads: { type: integer } + profiles: { type: integer } + lastReset: { type: string, format: date-time } + # VideoClipJob, VideoClip, VideoClipJobProcessing, VideoClipJobCompleted, VideoClipJobFailed, VideoClipUsageStats + # schemas removed - AI Clipping feature temporarily disabled + PostAnalytics: + type: object + properties: + impressions: { type: integer, example: 0 } + reach: { type: integer, example: 0 } + likes: { type: integer, example: 0 } + comments: { type: integer, example: 0 } + shares: { type: integer, example: 0 } + saves: { type: integer, example: 0, description: 'Number of saves/bookmarks (Instagram, Pinterest)' } + clicks: { type: integer, example: 0 } + views: { type: integer, example: 0 } + engagementRate: { type: number, example: 0 } + lastUpdated: { type: string, format: date-time } + PlatformAnalytics: + type: object + properties: + platform: { type: string } + status: { type: string, enum: [published, failed] } + accountId: { type: string } + accountUsername: { type: string, nullable: true } + analytics: + nullable: true + $ref: '#/components/schemas/PostAnalytics' + syncStatus: { type: string, enum: [synced, pending, unavailable], description: 'Sync state of analytics for this platform' } + platformPostUrl: { type: string, format: uri, nullable: true } + errorMessage: { type: string, nullable: true, description: 'Error details when status is failed' } + AnalyticsOverview: + type: object + properties: + totalPosts: { type: integer } + publishedPosts: { type: integer } + scheduledPosts: { type: integer } + lastSync: { type: string, format: date-time, nullable: true } + dataStaleness: + type: object + properties: + staleAccountCount: { type: integer, description: 'Number of accounts with stale analytics data' } + syncTriggered: { type: boolean, description: 'Whether a background sync was triggered for stale accounts' } + AnalyticsSinglePostResponse: + type: object + properties: + postId: { type: string } + latePostId: { type: string, nullable: true, description: 'Original Late post ID if scheduled via Late' } + status: { type: string, enum: [published, failed, partial], description: 'Overall post status. "partial" when some platforms published and others failed.' } + content: { type: string } + scheduledFor: { type: string, format: date-time } + publishedAt: { type: string, format: date-time, nullable: true } + analytics: + $ref: '#/components/schemas/PostAnalytics' + platformAnalytics: + type: array + items: + $ref: '#/components/schemas/PlatformAnalytics' + platform: { type: string } + platformPostUrl: { type: string, format: uri, nullable: true } + isExternal: { type: boolean } + syncStatus: { type: string, enum: [synced, pending, partial, unavailable], description: 'Overall sync state across all platforms' } + message: { type: string, nullable: true, description: 'Human-readable status message for pending, partial, or failed states' } + thumbnailUrl: { type: string, format: uri, nullable: true } + mediaType: { type: string, enum: [image, video, carousel, text], nullable: true } + mediaItems: + type: array + description: All media items for this post. Carousel posts contain one entry per slide. + items: + type: object + properties: + type: { type: string, enum: [image, video] } + url: { type: string, format: uri, description: Direct URL to the media } + thumbnail: { type: string, format: uri, description: Thumbnail URL (same as url for images) } + AnalyticsListResponse: + type: object + properties: + overview: + $ref: '#/components/schemas/AnalyticsOverview' + posts: + type: array + items: + type: object + properties: + _id: { type: string } + latePostId: { type: string, nullable: true, description: 'Original Late post ID if scheduled via Late' } + content: { type: string } + scheduledFor: { type: string, format: date-time } + publishedAt: { type: string, format: date-time } + status: { type: string } + analytics: + $ref: '#/components/schemas/PostAnalytics' + platforms: + type: array + items: + $ref: '#/components/schemas/PlatformAnalytics' + platform: { type: string } + platformPostUrl: { type: string, format: uri } + isExternal: { type: boolean } + profileId: { type: string, nullable: true } + thumbnailUrl: { type: string, format: uri } + mediaType: { type: string, enum: [image, video, gif, document, carousel, text] } + mediaItems: + type: array + description: All media items for this post. Carousel posts contain one entry per slide. + items: + type: object + properties: + type: { type: string, enum: [image, video] } + url: { type: string, format: uri, description: Direct URL to the media } + thumbnail: { type: string, format: uri, description: Thumbnail URL (same as url for images) } + pagination: + $ref: '#/components/schemas/Pagination' + accounts: + type: array + description: Connected social accounts (followerCount and followersLastUpdated only included if user has analytics add-on) + items: + $ref: '#/components/schemas/SocialAccount' + hasAnalyticsAccess: + type: boolean + description: Whether user has analytics add-on access + # LinkedIn Aggregate Analytics Responses + LinkedInAggregateAnalyticsTotalResponse: + type: object + description: Response for TOTAL aggregation (lifetime totals) + properties: + accountId: { type: string } + platform: { type: string, example: linkedin } + accountType: { type: string, example: personal } + username: { type: string } + aggregation: { type: string, enum: [TOTAL] } + dateRange: + type: object + nullable: true + properties: + startDate: { type: string, format: date } + endDate: { type: string, format: date } + analytics: + type: object + properties: + impressions: { type: integer, description: Total impressions across all posts } + reach: { type: integer, description: Unique members reached across all posts } + reactions: { type: integer, description: Total reactions across all posts } + comments: { type: integer, description: Total comments across all posts } + shares: { type: integer, description: Total reshares across all posts } + engagementRate: { type: number, description: Overall engagement rate as percentage } + note: { type: string } + lastUpdated: { type: string, format: date-time } + LinkedInAggregateAnalyticsDailyResponse: + type: object + description: Response for DAILY aggregation (time series breakdown) + properties: + accountId: { type: string } + platform: { type: string, example: linkedin } + accountType: { type: string, example: personal } + username: { type: string } + aggregation: { type: string, enum: [DAILY] } + dateRange: + type: object + nullable: true + properties: + startDate: { type: string, format: date } + endDate: { type: string, format: date } + analytics: + type: object + description: Daily breakdown of each metric as date/count pairs. Reach not available with DAILY aggregation. + properties: + impressions: + type: array + items: + type: object + properties: + date: { type: string, format: date } + count: { type: integer } + reactions: + type: array + items: + type: object + properties: + date: { type: string, format: date } + count: { type: integer } + comments: + type: array + items: + type: object + properties: + date: { type: string, format: date } + count: { type: integer } + shares: + type: array + items: + type: object + properties: + date: { type: string, format: date } + count: { type: integer } + skippedMetrics: + type: array + description: Metrics that were skipped due to API limitations + items: { type: string } + note: { type: string } + lastUpdated: { type: string, format: date-time } + # ============================================ + # Response Schemas + # ============================================ + # Posts Responses + PostsListResponse: + type: object + properties: + posts: + type: array + items: + $ref: '#/components/schemas/Post' + pagination: + $ref: '#/components/schemas/Pagination' + PostGetResponse: + type: object + properties: + post: + $ref: '#/components/schemas/Post' + PostCreateResponse: + type: object + properties: + message: + type: string + post: + $ref: '#/components/schemas/Post' + PostUpdateResponse: + type: object + properties: + message: + type: string + post: + $ref: '#/components/schemas/Post' + PostDeleteResponse: + type: object + properties: + message: + type: string + PostRetryResponse: + type: object + properties: + message: + type: string + post: + $ref: '#/components/schemas/Post' + # Profiles Responses + ProfilesListResponse: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/Profile' + ProfileGetResponse: + type: object + properties: + profile: + $ref: '#/components/schemas/Profile' + ProfileCreateResponse: + type: object + properties: + message: + type: string + profile: + $ref: '#/components/schemas/Profile' + ProfileUpdateResponse: + type: object + properties: + message: + type: string + profile: + $ref: '#/components/schemas/Profile' + ProfileDeleteResponse: + type: object + properties: + message: + type: string + # Accounts Responses + AccountsListResponse: + type: object + properties: + accounts: + type: array + items: + $ref: '#/components/schemas/SocialAccount' + hasAnalyticsAccess: + type: boolean + description: Whether user has analytics add-on access + AccountGetResponse: + type: object + properties: + account: + $ref: '#/components/schemas/SocialAccount' + FollowerStatsResponse: + type: object + properties: + accounts: + type: array + items: + $ref: '#/components/schemas/AccountWithFollowerStats' + dateRange: + type: object + properties: + from: + type: string + format: date-time + to: + type: string + format: date-time + aggregation: + type: string + enum: [daily, weekly, monthly] + # Media Responses + UploadedFile: + type: object + properties: + type: + type: string + enum: [image, video, document] + url: + type: string + format: uri + filename: + type: string + size: + type: integer + mimeType: + type: string + MediaUploadResponse: + type: object + properties: + files: + type: array + items: + $ref: '#/components/schemas/UploadedFile' + UploadTokenResponse: + type: object + properties: + token: + type: string + uploadUrl: + type: string + format: uri + expiresAt: + type: string + format: date-time + status: + type: string + enum: [pending, completed, expired] + UploadTokenStatusResponse: + type: object + properties: + token: + type: string + status: + type: string + enum: [pending, completed, expired] + files: + type: array + items: + $ref: '#/components/schemas/UploadedFile' + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + completedAt: + type: string + format: date-time + # Queue Responses + QueueSlotsResponse: + type: object + properties: + exists: + type: boolean + schedule: + $ref: '#/components/schemas/QueueSchedule' + nextSlots: + type: array + items: + type: string + format: date-time + QueueUpdateResponse: + type: object + properties: + success: + type: boolean + schedule: + $ref: '#/components/schemas/QueueSchedule' + nextSlots: + type: array + items: + type: string + format: date-time + reshuffledCount: + type: integer + QueueDeleteResponse: + type: object + properties: + success: + type: boolean + deleted: + type: boolean + QueuePreviewResponse: + type: object + properties: + profileId: + type: string + count: + type: integer + slots: + type: array + items: + type: string + format: date-time + QueueNextSlotResponse: + type: object + properties: + profileId: + type: string + nextSlot: + type: string + format: date-time + timezone: + type: string + # Tools Responses + DownloadFormat: + type: object + properties: + formatId: + type: string + ext: + type: string + resolution: + type: string + filesize: + type: integer + quality: + type: string + DownloadResponse: + type: object + properties: + url: + type: string + format: uri + title: + type: string + thumbnail: + type: string + format: uri + duration: + type: integer + formats: + type: array + items: + $ref: '#/components/schemas/DownloadFormat' + TranscriptSegment: + type: object + properties: + text: + type: string + start: + type: number + duration: + type: number + TranscriptResponse: + type: object + properties: + transcript: + type: string + segments: + type: array + items: + $ref: '#/components/schemas/TranscriptSegment' + language: + type: string + HashtagInfo: + type: object + properties: + hashtag: + type: string + status: + type: string + enum: [safe, banned, restricted, unknown] + postCount: + type: integer + HashtagCheckResponse: + type: object + properties: + hashtags: + type: array + items: + $ref: '#/components/schemas/HashtagInfo' + CaptionResponse: + type: object + properties: + caption: + type: string + # Users Responses + User: + type: object + properties: + _id: + type: string + email: + type: string + name: + type: string + role: + type: string + createdAt: + type: string + format: date-time + UsersListResponse: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + UserGetResponse: + type: object + properties: + user: + $ref: '#/components/schemas/User' +security: + - bearerAuth: [] +paths: + # ============================================ + # Tools API - Media Download & Utilities + # ============================================ + /v1/tools/youtube/download: + get: + operationId: downloadYouTubeVideo + tags: [Tools] + summary: Download YouTube video + description: | + Download YouTube videos or audio. Returns available formats or direct download URL. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: YouTube video URL or video ID + schema: + type: string + example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + - name: action + in: query + description: "Action to perform: 'download' returns download URL, 'formats' lists available formats" + schema: + type: string + enum: [download, formats] + default: download + - name: format + in: query + description: Desired format (when action=download) + schema: + type: string + enum: [video, audio] + default: video + - name: quality + in: query + description: Desired quality (when action=download) + schema: + type: string + enum: [hd, sd] + default: hd + - name: formatId + in: query + description: Specific format ID from formats list + schema: + type: string + responses: + "200": + description: Success + headers: + X-RateLimit-Limit: + schema: { type: string } + description: Daily rate limit + X-RateLimit-Remaining: + schema: { type: string } + description: Remaining calls today + X-RateLimit-Reset: + schema: { type: string } + description: Unix timestamp when limit resets + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + downloadUrl: { type: string, format: uri } + formats: + type: array + items: + type: object + properties: + id: { type: string } + label: { type: string } + ext: { type: string } + type: { type: string } + height: { type: integer } + width: { type: integer } + "401": + $ref: '#/components/responses/Unauthorized' + "403": + description: Tools API not available on free plan + "429": + description: Daily rate limit exceeded + + /v1/tools/youtube/transcript: + get: + operationId: getYouTubeTranscript + tags: [Tools] + summary: Get YouTube transcript + description: | + Extract transcript/captions from a YouTube video. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: YouTube video URL or video ID + schema: + type: string + - name: lang + in: query + description: Language code for transcript + schema: + type: string + default: en + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + videoId: { type: string } + language: { type: string } + fullText: { type: string } + segments: + type: array + items: + type: object + properties: + text: { type: string } + start: { type: number } + duration: { type: number } + "404": + description: No transcript available + "503": + description: Transcript service temporarily unavailable + + /v1/tools/instagram/download: + get: + operationId: downloadInstagramMedia + tags: [Tools] + summary: Download Instagram media + description: | + Download Instagram reels, posts, or photos. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: Instagram reel or post URL + schema: + type: string + example: "https://www.instagram.com/reel/ABC123/" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + downloadUrl: { type: string, format: uri } + + /v1/tools/instagram/hashtag-checker: + post: + operationId: checkInstagramHashtags + tags: [Tools] + summary: Check IG hashtag bans + description: | + Check if Instagram hashtags are banned, restricted, or safe to use. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [hashtags] + properties: + hashtags: + type: array + maxItems: 20 + items: + type: string + example: ["travel", "followforfollow", "fitness"] + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + results: + type: array + items: + type: object + properties: + hashtag: { type: string } + status: + type: string + enum: [banned, restricted, safe, unknown] + reason: { type: string } + confidence: { type: number } + summary: + type: object + properties: + banned: { type: integer } + restricted: { type: integer } + safe: { type: integer } + + /v1/tools/tiktok/download: + get: + operationId: downloadTikTokVideo + tags: [Tools] + summary: Download TikTok video + description: | + Download TikTok videos with or without watermark. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: TikTok video URL or ID + schema: + type: string + - name: action + in: query + description: "'formats' to list available formats" + schema: + type: string + enum: [download, formats] + default: download + - name: formatId + in: query + description: Specific format ID (0 = no watermark, etc.) + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + downloadUrl: { type: string, format: uri } + formats: + type: array + items: + type: object + properties: + id: { type: string } + label: { type: string } + ext: { type: string } + + /v1/tools/twitter/download: + get: + operationId: downloadTwitterMedia + tags: [Tools] + summary: Download Twitter/X media + description: | + Download videos from Twitter/X posts. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: Twitter/X post URL + schema: + type: string + example: "https://x.com/user/status/123456789" + - name: action + in: query + schema: + type: string + enum: [download, formats] + default: download + - name: formatId + in: query + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + downloadUrl: { type: string, format: uri } + + /v1/tools/facebook/download: + get: + operationId: downloadFacebookVideo + tags: [Tools] + summary: Download Facebook video + description: | + Download videos and reels from Facebook. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: Facebook video or reel URL + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + downloadUrl: { type: string, format: uri } + thumbnail: { type: string, format: uri } + + /v1/tools/linkedin/download: + get: + operationId: downloadLinkedInVideo + tags: [Tools] + summary: Download LinkedIn video + description: | + Download videos from LinkedIn posts. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: LinkedIn post URL + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + downloadUrl: { type: string, format: uri } + + /v1/tools/bluesky/download: + get: + operationId: downloadBlueskyMedia + tags: [Tools] + summary: Download Bluesky media + description: | + Download videos from Bluesky posts. + + Rate limits: Build (50/day), Accelerate (500/day), Unlimited (unlimited). + security: + - bearerAuth: [] + parameters: + - name: url + in: query + required: true + description: Bluesky post URL + schema: + type: string + example: "https://bsky.app/profile/user.bsky.social/post/abc123" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + title: { type: string } + text: { type: string } + downloadUrl: { type: string, format: uri } + thumbnail: { type: string, format: uri } + + # ============================================ + # Validate + # ============================================ + /v1/tools/validate/post-length: + post: + operationId: validatePostLength + tags: [Validate] + summary: Validate post character count + description: | + Check weighted character count per platform and whether the text is within each platform's limit. + + Twitter/X uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length. + + Returns counts and limits for all 15 supported platform variants. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + description: The post text to check + example: "Check out https://zernio.com for scheduling posts!" + responses: + "200": + description: Character counts per platform + content: + application/json: + schema: + type: object + properties: + text: { type: string } + platforms: + type: object + additionalProperties: + type: object + properties: + count: { type: integer, description: "Character count for this platform" } + limit: { type: integer, description: "Maximum allowed characters" } + valid: { type: boolean, description: "Whether the text is within the limit" } + example: + twitter: { count: 51, limit: 280, valid: true } + twitterPremium: { count: 51, limit: 25000, valid: true } + instagram: { count: 51, limit: 2200, valid: true } + bluesky: { count: 51, limit: 300, valid: true } + snapchat: { count: 51, limit: 160, valid: true } + + /v1/tools/validate/post: + post: + operationId: validatePost + tags: [Validate] + summary: Validate post content + description: | + Dry-run the full post validation pipeline without publishing. Catches issues like missing media for Instagram/TikTok/YouTube, hashtag limits, invalid thread formats, Facebook Reel requirements, and character limit violations. + + Accepts the same body as `POST /v1/posts`. Does NOT validate accounts, process media, or track usage. This is content-only validation. + + Returns errors for failures and warnings for near-limit content (>90% of character limit). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [platforms] + properties: + content: + type: string + description: Post text content + example: "Check out this video!" + platforms: + type: array + description: Target platforms (same format as POST /v1/posts) + items: + type: object + required: [platform] + properties: + platform: + type: string + enum: [twitter, instagram, tiktok, youtube, facebook, linkedin, bluesky, threads, reddit, pinterest, telegram, snapchat, googlebusiness] + customContent: { type: string } + platformSpecificData: { type: object } + customMedia: + type: array + items: + type: object + properties: + url: { type: string } + type: { type: string, enum: [image, video] } + example: + - platform: youtube + - platform: twitter + mediaItems: + type: array + description: Root media items shared across platforms + items: + type: object + properties: + url: { type: string, format: uri } + type: { type: string, enum: [image, video] } + responses: + "200": + description: Validation result + content: + application/json: + schema: + oneOf: + - type: object + description: Valid post + properties: + valid: { type: boolean } + message: { type: string, example: "No validation issues found." } + warnings: + type: array + items: + type: object + properties: + platform: { type: string } + warning: { type: string } + - type: object + description: Invalid post + properties: + valid: { type: boolean } + errors: + type: array + items: + type: object + properties: + platform: { type: string } + error: { type: string } + warnings: + type: array + items: + type: object + properties: + platform: { type: string } + warning: { type: string } + + /v1/tools/validate/media: + post: + operationId: validateMedia + tags: [Validate] + summary: Validate media URL + description: | + Check if a media URL is accessible and return metadata (content type, file size) plus per-platform size limit comparisons. + + Performs a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection. + + Platform limits are sourced from each platform's actual upload constraints. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: Public media URL to validate + example: "https://example.com/image.jpg" + responses: + "200": + description: Media validation result + content: + application/json: + schema: + type: object + properties: + valid: { type: boolean } + url: { type: string, format: uri } + error: { type: string, description: "Error message if valid is false" } + contentType: { type: string, example: "image/jpeg" } + size: { type: integer, nullable: true, description: "File size in bytes" } + sizeFormatted: { type: string, example: "245 KB" } + type: { type: string, enum: [image, video, unknown] } + platformLimits: + type: object + description: Per-platform size limit comparison (only present when size and type are known) + additionalProperties: + type: object + properties: + limit: { type: integer, description: "Platform size limit in bytes" } + limitFormatted: { type: string } + withinLimit: { type: boolean } + example: + instagram: { limit: 8388608, limitFormatted: "8.0 MB", withinLimit: true } + twitter: { limit: 5242880, limitFormatted: "5.0 MB", withinLimit: true } + bluesky: { limit: 1000000, limitFormatted: "977 KB", withinLimit: true } + + /v1/tools/validate/subreddit: + get: + operationId: validateSubreddit + tags: [Validate] + summary: Check subreddit existence + description: | + Check if a subreddit exists and return basic info (title, subscriber count, NSFW status, post types allowed). + + Uses Reddit's public JSON API (no Reddit auth needed). Returns `exists: false` for private, banned, or nonexistent subreddits. + security: + - bearerAuth: [] + parameters: + - name: name + in: query + required: true + description: Subreddit name (with or without "r/" prefix) + schema: + type: string + example: "programming" + responses: + "200": + description: Subreddit lookup result + content: + application/json: + schema: + oneOf: + - type: object + description: Subreddit exists + properties: + exists: { type: boolean } + subreddit: + type: object + properties: + name: { type: string, example: "programming" } + title: { type: string, example: "programming" } + description: { type: string, example: "Computer Programming" } + subscribers: { type: integer, example: 6844284 } + isNSFW: { type: boolean } + type: { type: string, enum: [public, private, restricted], example: "public" } + allowImages: { type: boolean } + allowVideos: { type: boolean } + - type: object + description: Subreddit not found + properties: + exists: { type: boolean } + error: { type: string } + + # ============================================ + # Analytics + # ============================================ + /v1/analytics: + get: + operationId: getAnalytics + tags: [Analytics] + summary: Get post analytics + description: | + Returns analytics for posts. With postId, returns a single post. Without it, returns a paginated list with overview stats. + Accepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days. + Single post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats. + parameters: + - name: postId + in: query + schema: { type: string } + description: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics. + - name: platform + in: query + schema: { type: string } + description: Filter by platform (default "all") + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID (default "all") + - name: source + in: query + schema: { type: string, enum: [all, late, external], default: all } + description: "Filter by post source: late (posted via Zernio API), external (synced from platform), all (default)" + - name: fromDate + in: query + schema: { type: string, format: date } + description: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days. + - name: toDate + in: query + schema: { type: string, format: date } + description: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted. + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + description: Page size (default 50) + - name: page + in: query + schema: { type: integer, minimum: 1, default: 1 } + description: Page number (default 1) + - name: sortBy + in: query + schema: { type: string, enum: [date, engagement, impressions, reach, likes, comments, shares, saves, clicks, views], default: date } + description: Sort by date, engagement, or a specific metric + - name: order + in: query + schema: { type: string, enum: [asc, desc], default: desc } + description: Sort order + responses: + '200': + description: Analytics result + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AnalyticsSinglePostResponse' + - $ref: '#/components/schemas/AnalyticsListResponse' + examples: + singlePost: + summary: Single post analytics (Late post with synced analytics) + value: + postId: "65f1c0a9e2b5af0012ab34cd" + latePostId: null + status: "published" + content: "Check out our new product launch!" + scheduledFor: "2024-11-01T10:00:00Z" + publishedAt: "2024-11-01T10:00:05Z" + analytics: + impressions: 15420 + reach: 12350 + likes: 342 + comments: 28 + shares: 45 + saves: 0 + clicks: 189 + views: 0 + engagementRate: 2.78 + lastUpdated: "2024-11-02T08:30:00Z" + platformAnalytics: + - platform: "twitter" + status: "published" + accountId: "64e1f0a9e2b5af0012ab34cd" + accountUsername: "@acmecorp" + analytics: + impressions: 15420 + reach: 12350 + likes: 342 + comments: 28 + shares: 45 + saves: 0 + clicks: 189 + views: 0 + engagementRate: 2.78 + lastUpdated: "2024-11-02T08:30:00Z" + syncStatus: "synced" + platformPostUrl: "https://twitter.com/acmecorp/status/123456789" + errorMessage: null + platform: "twitter" + platformPostUrl: "https://twitter.com/acmecorp/status/123456789" + isExternal: false + syncStatus: "synced" + message: null + thumbnailUrl: "https://storage.example.com/image.jpg" + mediaType: "image" + mediaItems: + - type: "image" + url: "https://storage.example.com/image.jpg" + thumbnail: "https://storage.example.com/image.jpg" + postList: + summary: Paginated analytics list + description: | + Note: The list endpoint returns External Post IDs. Posts originally + scheduled via Zernio will have isExternal: true in this response. + Use platformPostUrl to correlate with your original Zernio Post IDs. + value: + overview: + totalPosts: 156 + publishedPosts: 156 + scheduledPosts: 0 + lastSync: "2024-11-02T08:30:00Z" + dataStaleness: + staleAccountCount: 0 + syncTriggered: false + posts: + - _id: "65f1c0a9e2b5af0012ab34cd" + latePostId: "65f1c0a9e2b5af0012ab34ab" + content: "Check out our new product launch!" + scheduledFor: "2024-11-01T10:00:00Z" + publishedAt: "2024-11-01T10:00:05Z" + status: "published" + analytics: + impressions: 15420 + reach: 12350 + likes: 342 + comments: 28 + shares: 45 + saves: 0 + clicks: 189 + views: 0 + engagementRate: 2.78 + lastUpdated: "2024-11-02T08:30:00Z" + platforms: + - platform: "instagram" + status: "published" + accountId: "64e1f0a9e2b5af0012ab34cd" + accountUsername: "@acmecorp" + analytics: + impressions: 15420 + reach: 12350 + likes: 342 + comments: 28 + shares: 45 + saves: 0 + clicks: 189 + views: 0 + engagementRate: 2.78 + lastUpdated: "2024-11-02T08:30:00Z" + syncStatus: "synced" + platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/" + errorMessage: null + platform: "instagram" + platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/" + isExternal: true + profileId: "64e1f0a9e2b5af0012ab34cd" + thumbnailUrl: "https://storage.example.com/thumb.jpg" + mediaType: "carousel" + mediaItems: + - type: "image" + url: "https://storage.example.com/slide1.jpg" + thumbnail: "https://storage.example.com/slide1.jpg" + - type: "image" + url: "https://storage.example.com/slide2.jpg" + thumbnail: "https://storage.example.com/slide2.jpg" + pagination: + page: 1 + limit: 50 + total: 156 + pages: 4 + accounts: + - _id: "64e1f0..." + platform: "twitter" + username: "@acmecorp" + displayName: "Acme Corp" + isActive: true + hasAnalyticsAccess: true + '202': + description: Analytics are being synced from the platform (single post lookup only). The response body matches AnalyticsSinglePostResponse with syncStatus "pending" and a message. + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsSinglePostResponse' + '400': + description: Validation error + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: Invalid query parameters } + details: { type: object, description: 'Detailed validation errors' } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: Analytics add-on required } + code: { type: string, example: analytics_addon_required } + '404': { $ref: '#/components/responses/NotFound' } + '424': + description: Post failed to publish on all platforms. Analytics are unavailable. (single post lookup only) + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsSinglePostResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /v1/analytics/youtube/daily-views: + get: + operationId: getYouTubeDailyViews + tags: [Analytics] + summary: Get YouTube daily views + description: | + Returns daily view counts for a YouTube video including views, watch time, and subscriber changes. + Requires yt-analytics.readonly scope (re-authorization may be needed). Data has a 2-3 day delay. Max 90 days, defaults to last 30 days. + parameters: + - name: videoId + in: query + required: true + schema: { type: string } + description: The YouTube video ID (e.g., "dQw4w9WgXcQ") + - name: accountId + in: query + required: true + schema: { type: string } + description: The Zernio account ID for the YouTube account + - name: startDate + in: query + schema: { type: string, format: date } + description: Start date (YYYY-MM-DD). Defaults to 30 days ago. + - name: endDate + in: query + schema: { type: string, format: date } + description: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency). + responses: + '200': + description: Daily views breakdown + content: + application/json: + schema: + $ref: '#/components/schemas/YouTubeDailyViewsResponse' + examples: + success: + summary: Successful response with daily views + value: + success: true + videoId: "dQw4w9WgXcQ" + dateRange: + startDate: "2025-01-01" + endDate: "2025-01-12" + totalViews: 12345 + dailyViews: + - date: "2025-01-12" + views: 1234 + estimatedMinutesWatched: 567.5 + averageViewDuration: 45.2 + subscribersGained: 10 + subscribersLost: 2 + likes: 89 + comments: 12 + shares: 5 + - date: "2025-01-11" + views: 987 + estimatedMinutesWatched: 432.1 + averageViewDuration: 43.8 + subscribersGained: 8 + subscribersLost: 1 + likes: 67 + comments: 8 + shares: 3 + lastSyncedAt: "2025-01-15T12:00:00Z" + scopeStatus: + hasAnalyticsScope: true + '400': + description: Bad request (missing or invalid parameters) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + examples: + missingVideoId: + value: + error: "videoId is required" + invalidDate: + value: + error: "Invalid startDate format. Use YYYY-MM-DD." + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Analytics add-on required" } + code: { type: string, example: "analytics_addon_required" } + '403': + description: Access denied to this account + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Access denied to this account" } + '412': + description: Missing YouTube Analytics scope + content: + application/json: + schema: + $ref: '#/components/schemas/YouTubeScopeMissingResponse' + examples: + scopeMissing: + summary: YouTube Analytics scope not granted + value: + success: false + error: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions." + code: "youtube_analytics_scope_missing" + scopeStatus: + hasAnalyticsScope: false + requiresReauthorization: true + reauthorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=..." + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: false } + error: { type: string } + + /v1/analytics/daily-metrics: + get: + operationId: getDailyMetrics + tags: [Analytics] + summary: Get daily aggregated metrics + description: | + Returns daily aggregated analytics metrics and a per-platform breakdown. + Each day includes post count, platform distribution, and summed metrics (impressions, reach, likes, comments, shares, saves, clicks, views). + Defaults to the last 180 days. Requires the Analytics add-on. + parameters: + - name: platform + in: query + schema: { type: string } + description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID. Omit for all profiles. + - name: fromDate + in: query + schema: { type: string, format: date-time } + description: Inclusive start date (ISO 8601). Defaults to 180 days ago. + - name: toDate + in: query + schema: { type: string, format: date-time } + description: Inclusive end date (ISO 8601). Defaults to now. + - name: source + in: query + schema: + type: string + enum: [all, late, external] + default: all + description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. + responses: + '200': + description: Daily metrics and platform breakdown + content: + application/json: + schema: + type: object + properties: + dailyData: + type: array + items: + type: object + properties: + date: { type: string, example: "2025-12-01" } + postCount: { type: integer, example: 3 } + platforms: + type: object + additionalProperties: { type: integer } + example: { instagram: 2, twitter: 1 } + metrics: + type: object + properties: + impressions: { type: integer } + reach: { type: integer } + likes: { type: integer } + comments: { type: integer } + shares: { type: integer } + saves: { type: integer } + clicks: { type: integer } + views: { type: integer } + platformBreakdown: + type: array + items: + type: object + properties: + platform: { type: string, example: "instagram" } + postCount: { type: integer, example: 142 } + impressions: { type: integer } + reach: { type: integer } + likes: { type: integer } + comments: { type: integer } + shares: { type: integer } + saves: { type: integer } + clicks: { type: integer } + views: { type: integer } + examples: + success: + value: + dailyData: + - date: "2025-12-01" + postCount: 3 + platforms: { instagram: 2, twitter: 1 } + metrics: + impressions: 4520 + reach: 3200 + likes: 312 + comments: 45 + shares: 28 + saves: 67 + clicks: 89 + views: 1560 + platformBreakdown: + - platform: "instagram" + postCount: 142 + impressions: 89400 + reach: 62100 + likes: 8930 + comments: 1204 + shares: 567 + saves: 2103 + clicks: 3402 + views: 45200 + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Analytics add-on required" } + code: { type: string, example: "analytics_addon_required" } + + /v1/analytics/best-time: + get: + operationId: getBestTimeToPost + tags: [Analytics] + summary: Get best times to post + description: | + Returns the best times to post based on historical engagement data. + Groups all published posts by day of week and hour (UTC), calculating average engagement per slot. + Use this to auto-schedule posts at optimal times. Requires the Analytics add-on. + parameters: + - name: platform + in: query + schema: { type: string } + description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID. Omit for all profiles. + - name: source + in: query + schema: + type: string + enum: [all, late, external] + default: all + description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. + responses: + '200': + description: Best time slots + content: + application/json: + schema: + type: object + properties: + slots: + type: array + items: + type: object + properties: + day_of_week: { type: integer, description: "0=Monday, 6=Sunday", minimum: 0, maximum: 6 } + hour: { type: integer, description: "Hour in UTC (0-23)", minimum: 0, maximum: 23 } + avg_engagement: { type: number, description: "Average engagement (likes + comments + shares + saves)" } + post_count: { type: integer, description: "Number of posts in this slot" } + examples: + success: + value: + slots: + - day_of_week: 2 + hour: 18 + avg_engagement: 510.3 + post_count: 15 + - day_of_week: 0 + hour: 9 + avg_engagement: 342.5 + post_count: 12 + - day_of_week: 4 + hour: 12 + avg_engagement: 289.1 + post_count: 8 + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Analytics add-on required" } + requiresAddon: { type: boolean, example: true } + + /v1/analytics/content-decay: + get: + operationId: getContentDecay + tags: [Analytics] + summary: Get content performance decay + description: | + Returns how engagement accumulates over time after a post is published. + Each bucket shows what percentage of the post's total engagement had been reached by that time window. + Useful for understanding content lifespan (e.g. "posts reach 78% of total engagement within 24 hours"). + Requires the Analytics add-on. + parameters: + - name: platform + in: query + schema: { type: string } + description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID. Omit for all profiles. + - name: source + in: query + schema: + type: string + enum: [all, late, external] + default: all + description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. + responses: + '200': + description: Content decay buckets + content: + application/json: + schema: + type: object + properties: + buckets: + type: array + items: + type: object + properties: + bucket_order: { type: integer, description: "Sort order (0 = earliest, 6 = latest)" } + bucket_label: { type: string, description: "Human-readable label" } + avg_pct_of_final: { type: number, description: "Average % of final engagement reached (0-100)" } + post_count: { type: integer, description: "Number of posts with data in this bucket" } + examples: + success: + value: + buckets: + - bucket_order: 0 + bucket_label: "0-6h" + avg_pct_of_final: 45.2 + post_count: 89 + - bucket_order: 1 + bucket_label: "6-12h" + avg_pct_of_final: 18.7 + post_count: 89 + - bucket_order: 2 + bucket_label: "12-24h" + avg_pct_of_final: 14.1 + post_count: 85 + - bucket_order: 3 + bucket_label: "1-2d" + avg_pct_of_final: 9.3 + post_count: 82 + - bucket_order: 4 + bucket_label: "2-7d" + avg_pct_of_final: 8.1 + post_count: 78 + - bucket_order: 5 + bucket_label: "7-30d" + avg_pct_of_final: 3.8 + post_count: 64 + - bucket_order: 6 + bucket_label: "30d+" + avg_pct_of_final: 0.8 + post_count: 41 + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Analytics add-on required" } + requiresAddon: { type: boolean, example: true } + + /v1/analytics/posting-frequency: + get: + operationId: getPostingFrequency + tags: [Analytics] + summary: Get posting frequency vs engagement + description: | + Returns the correlation between posting frequency (posts per week) and engagement rate, broken down by platform. + Helps find the optimal posting cadence for each platform. Each row represents a specific (platform, posts_per_week) combination + with the average engagement rate observed across all weeks matching that frequency. + Requires the Analytics add-on. + parameters: + - name: platform + in: query + schema: { type: string } + description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID. Omit for all profiles. + - name: source + in: query + schema: + type: string + enum: [all, late, external] + default: all + description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. + responses: + '200': + description: Posting frequency data + content: + application/json: + schema: + type: object + properties: + frequency: + type: array + items: + type: object + properties: + platform: { type: string, example: "instagram" } + posts_per_week: { type: integer, description: "Number of posts published that week" } + avg_engagement_rate: { type: number, description: "Average engagement rate as percentage (0-100)" } + avg_engagement: { type: number, description: "Average raw engagement (likes+comments+shares+saves)" } + weeks_count: { type: integer, description: "Number of calendar weeks observed at this frequency" } + examples: + success: + value: + frequency: + - platform: "instagram" + posts_per_week: 2 + avg_engagement_rate: 44.4 + avg_engagement: 512 + weeks_count: 18 + - platform: "instagram" + posts_per_week: 4 + avg_engagement_rate: 5.9 + avg_engagement: 203 + weeks_count: 6 + - platform: "facebook" + posts_per_week: 3 + avg_engagement_rate: 12.5 + avg_engagement: 87 + weeks_count: 10 + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Analytics add-on required" } + requiresAddon: { type: boolean, example: true } + + /v1/analytics/post-timeline: + get: + operationId: getPostTimeline + tags: [Analytics] + summary: Get post analytics timeline + description: | + Returns a daily timeline of analytics metrics for a specific post, showing how impressions, likes, + and other metrics evolved day-by-day since publishing. Each row represents one day of data per platform. + For multi-platform Zernio posts, returns separate rows for each platform. Requires the Analytics add-on. + parameters: + - name: postId + in: query + required: true + schema: { type: string } + description: | + The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID. + - name: fromDate + in: query + schema: { type: string, format: date-time } + description: Start of date range (ISO 8601). Defaults to 90 days ago. + - name: toDate + in: query + schema: { type: string, format: date-time } + description: End of date range (ISO 8601). Defaults to now. + responses: + '200': + description: Daily analytics timeline + content: + application/json: + schema: + type: object + properties: + postId: + type: string + description: The postId that was requested + timeline: + type: array + items: + type: object + properties: + date: { type: string, format: date, description: "Date in YYYY-MM-DD format" } + platform: { type: string, description: "Platform name (e.g. instagram, tiktok)" } + platformPostId: { type: string, description: "Platform-specific post ID" } + impressions: { type: integer, description: "Total impressions on this date" } + reach: { type: integer, description: "Total reach on this date" } + likes: { type: integer, description: "Total likes on this date" } + comments: { type: integer, description: "Total comments on this date" } + shares: { type: integer, description: "Total shares on this date" } + saves: { type: integer, description: "Total saves on this date" } + clicks: { type: integer, description: "Total clicks on this date" } + views: { type: integer, description: "Total views on this date" } + examples: + single_platform: + summary: Single-platform post timeline + value: + postId: "6507a1b2c3d4e5f6a7b8c9d0" + timeline: + - date: "2025-01-15" + platform: "instagram" + platformPostId: "17902345678901234" + impressions: 1200 + reach: 980 + likes: 45 + comments: 3 + shares: 12 + saves: 8 + clicks: 25 + views: 0 + - date: "2025-01-16" + platform: "instagram" + platformPostId: "17902345678901234" + impressions: 2400 + reach: 1850 + likes: 92 + comments: 7 + shares: 21 + saves: 15 + clicks: 48 + views: 0 + '400': + description: Missing required postId parameter + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Missing required parameter: postId" } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Analytics add-on required" } + code: { type: string, example: "analytics_addon_required" } + '403': + description: Forbidden (post belongs to another user or API key scope violation) + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Forbidden" } + '404': + description: Post not found + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "Post not found" } + + /v1/account-groups: + get: + operationId: listAccountGroups + tags: [Account Groups] + summary: List groups + description: Returns all account groups for the authenticated user, including group names and associated account IDs. + responses: + '200': + description: Groups + content: + application/json: + schema: + type: object + properties: + groups: + type: array + items: + type: object + properties: + _id: { type: string } + name: { type: string } + accountIds: + type: array + items: { type: string } + examples: + example: + value: + groups: + - _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Marketing Accounts" + accountIds: + - "64e1f0a9e2b5af0012ab34cd" + - "64e1f0a9e2b5af0012ab34ce" + - _id: "6507a1b2c3d4e5f6a7b8c9d1" + name: "Personal Brand" + accountIds: + - "64e1f0a9e2b5af0012ab34cf" + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: createAccountGroup + tags: [Account Groups] + summary: Create group + description: Creates a new account group with a name and a list of social account IDs. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, accountIds] + properties: + name: { type: string } + accountIds: + type: array + items: { type: string } + example: + name: "Marketing Accounts" + accountIds: + - "64e1f0a9e2b5af0012ab34cd" + - "64e1f0a9e2b5af0012ab34ce" + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + properties: + message: { type: string } + group: + type: object + properties: + _id: { type: string } + name: { type: string } + accountIds: + type: array + items: { type: string } + example: + message: "Account group created successfully" + group: + _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Marketing Accounts" + accountIds: + - "64e1f0a9e2b5af0012ab34cd" + - "64e1f0a9e2b5af0012ab34ce" + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { description: Group name already exists } + /v1/account-groups/{groupId}: + put: + operationId: updateAccountGroup + tags: [Account Groups] + summary: Update group + description: Updates the name or account list of an existing group. You can rename the group, change its accounts, or both. + parameters: + - name: groupId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + accountIds: + type: array + items: { type: string } + example: + name: "Updated Marketing Accounts" + accountIds: + - "64e1f0a9e2b5af0012ab34cd" + - "64e1f0a9e2b5af0012ab34ce" + - "64e1f0a9e2b5af0012ab34cf" + responses: + '200': + description: Updated + content: + application/json: + schema: + type: object + properties: + message: { type: string } + group: + type: object + example: + message: "Account group updated successfully" + group: + _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Updated Marketing Accounts" + accountIds: + - "64e1f0a9e2b5af0012ab34cd" + - "64e1f0a9e2b5af0012ab34ce" + - "64e1f0a9e2b5af0012ab34cf" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: Group name already exists } + delete: + operationId: deleteAccountGroup + tags: [Account Groups] + summary: Delete group + description: Permanently deletes an account group. The accounts themselves are not affected. + parameters: + - name: groupId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Deleted + content: + application/json: + schema: + type: object + properties: + message: { type: string } + example: + message: "Account group deleted successfully" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + /v1/media/presign: + post: + operationId: getMediaPresignedUrl + tags: [Media] + summary: Get presigned upload URL + description: Get a presigned URL to upload files directly to cloud storage (up to 5GB). Returns an uploadUrl and publicUrl. PUT your file to the uploadUrl, then use the publicUrl in your posts. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [filename, contentType] + properties: + filename: + type: string + description: Name of the file to upload + example: "my-video.mp4" + contentType: + type: string + description: MIME type of the file + enum: + - image/jpeg + - image/jpg + - image/png + - image/webp + - image/gif + - video/mp4 + - video/mpeg + - video/quicktime + - video/avi + - video/x-msvideo + - video/webm + - video/x-m4v + - application/pdf + example: "video/mp4" + size: + type: integer + description: Optional file size in bytes for pre-validation (max 5GB) + example: 15234567 + responses: + '200': + description: Presigned URL generated successfully + content: + application/json: + schema: + type: object + properties: + uploadUrl: + type: string + format: uri + description: Presigned URL to PUT your file to (expires in 1 hour) + publicUrl: + type: string + format: uri + description: Public URL where the file will be accessible after upload + key: + type: string + description: Storage key/path of the file + type: + type: string + enum: [image, video, document] + description: Detected file type based on content type + example: + uploadUrl: "" + publicUrl: "https://media.zernio.com/temp/1234567890_abc123_my-video.mp4" + key: "temp/1234567890_abc123_my-video.mp4" + type: "video" + '400': + description: Invalid request (missing filename, contentType, or unsupported content type) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + examples: + missing_filename: + value: { error: "filename and contentType are required" } + invalid_type: + value: { error: "Content type not allowed: text/plain" } + file_too_large: + value: { error: "File too large. Maximum size is 5GB." } + '401': { $ref: '#/components/responses/Unauthorized' } + # Video Clips API paths removed - AI Clipping feature temporarily disabled + # /v1/video-clips, /v1/video-clips-replicate, /v1/video-clips/process, + # /v1/video-clips/jobs, /v1/video-clips/status/{jobId}, /v1/video-clips/monthly-stats + /v1/reddit/search: + get: + operationId: searchReddit + tags: [Reddit Search] + summary: Search posts + description: Search Reddit posts using a connected account. Optionally scope to a specific subreddit. + parameters: + - name: accountId + in: query + required: true + schema: { type: string } + - name: subreddit + in: query + schema: { type: string } + - name: q + in: query + required: true + schema: { type: string } + - name: restrict_sr + in: query + schema: { type: string, enum: ['0','1'] } + - name: sort + in: query + schema: { type: string, enum: [relevance, hot, top, new, comments], default: new } + - name: limit + in: query + schema: { type: integer, default: 25, maximum: 100 } + - name: after + in: query + schema: { type: string } + responses: + '200': + description: Search results + content: + application/json: + schema: + type: object + properties: + posts: + type: array + items: + type: object + properties: + id: { type: string } + title: { type: string } + selftext: { type: string } + author: { type: string } + subreddit: { type: string } + score: { type: integer } + num_comments: { type: integer } + created_utc: { type: number } + permalink: { type: string } + after: { type: string } + example: + posts: + - id: "1abc234" + title: "How to grow on social media in 2025" + selftext: "Here are my tips..." + author: "marketingpro" + subreddit: "socialmedia" + score: 156 + num_comments: 42 + created_utc: 1730000000 + permalink: "/r/socialmedia/comments/1abc234/how_to_grow/" + after: "t3_1abc234" + '400': { description: Missing params } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + /v1/reddit/feed: + get: + operationId: getRedditFeed + tags: [Reddit Search] + summary: Get subreddit feed + description: Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination. + parameters: + - name: accountId + in: query + required: true + schema: { type: string } + - name: subreddit + in: query + schema: { type: string } + - name: sort + in: query + schema: { type: string, enum: [hot, new, top, rising], default: hot } + - name: limit + in: query + schema: { type: integer, default: 25, maximum: 100 } + - name: after + in: query + schema: { type: string } + - name: t + in: query + schema: { type: string, enum: [hour, day, week, month, year, all] } + responses: + '200': + description: Feed items + content: + application/json: + schema: + type: object + properties: + posts: + type: array + items: + type: object + after: { type: string } + example: + posts: + - id: "1xyz789" + title: "Top marketing trends this week" + author: "trendwatcher" + subreddit: "marketing" + score: 892 + num_comments: 134 + created_utc: 1730100000 + permalink: "/r/marketing/comments/1xyz789/top_marketing_trends/" + - id: "1def456" + title: "My social media strategy that worked" + author: "growthexpert" + subreddit: "marketing" + score: 567 + num_comments: 89 + created_utc: 1730050000 + permalink: "/r/marketing/comments/1def456/my_social_media_strategy/" + after: "t3_1def456" + '400': { description: Missing params } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + /v1/usage-stats: + get: + operationId: getUsageStats + tags: [Usage] + summary: Get plan and usage stats + description: Returns the current plan name, billing period, plan limits, and usage counts. + responses: + '200': + description: Usage stats + content: + application/json: + schema: + $ref: '#/components/schemas/UsageStats' + example: + planName: "Pro" + billingPeriod: "monthly" + signupDate: "2024-01-15T10:30:00Z" + billingAnchorDay: 15 + limits: + uploads: 500 + profiles: 10 + usage: + uploads: 127 + profiles: 3 + lastReset: "2024-11-01T00:00:00Z" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + /v1/posts: + get: + operationId: listPosts + tags: [Posts] + summary: List posts + description: Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform. + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - name: status + in: query + schema: { type: string, enum: [draft, scheduled, published, failed] } + - name: platform + in: query + schema: { type: string, example: twitter } + - name: profileId + in: query + schema: { type: string } + - name: createdBy + in: query + schema: { type: string } + - name: dateFrom + in: query + schema: { type: string, format: date } + - name: dateTo + in: query + schema: { type: string, format: date } + - name: includeHidden + in: query + schema: { type: boolean, default: false } + - name: search + in: query + schema: { type: string } + description: Search posts by text content. + - name: sortBy + in: query + schema: + type: string + enum: [scheduled-desc, scheduled-asc, created-desc, created-asc, status, platform] + default: scheduled-desc + description: Sort order for results. + responses: + '200': + description: Paginated posts + content: + application/json: + schema: + $ref: '#/components/schemas/PostsListResponse' + examples: + scheduledPost: + summary: Scheduled post (pending publish) + value: + posts: + - _id: "65f1c0a9e2b5af0012ab34cd" + title: "Launch post" + content: "We just launched!" + status: "scheduled" + scheduledFor: "2024-11-01T10:00:00Z" + timezone: "UTC" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0..." + platform: "twitter" + username: "@acme" + displayName: "Acme Corp" + isActive: true + status: "pending" + tags: ["launch"] + createdAt: "2024-10-01T12:00:00Z" + updatedAt: "2024-10-01T12:00:00Z" + pagination: + page: 1 + limit: 10 + total: 1 + pages: 1 + publishedPost: + summary: Published post with platformPostUrl + value: + posts: + - _id: "65f1c0a9e2b5af0012ab34cd" + title: "Launch post" + content: "We just launched!" + status: "published" + scheduledFor: "2024-11-01T10:00:00Z" + publishedAt: "2024-11-01T10:00:05Z" + timezone: "UTC" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0a9e2b5af0012ab34de" + platform: "twitter" + username: "@acmecorp" + displayName: "Acme Corporation" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + platformPostId: "1852634789012345678" + platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678" + - platform: "linkedin" + accountId: + _id: "64e1f0a9e2b5af0012ab34ef" + platform: "linkedin" + username: "acme-corporation" + displayName: "Acme Corporation" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:06Z" + platformPostId: "urn:li:share:7123456789012345678" + platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678" + tags: ["launch"] + createdAt: "2024-10-01T12:00:00Z" + updatedAt: "2024-11-01T10:00:06Z" + pagination: + page: 1 + limit: 10 + total: 1 + pages: 1 + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: createPost + tags: [Posts] + summary: Create post + description: | + Create and optionally publish a post. Immediate posts (publishNow: true) include platformPostUrl in the response. + Content is optional when media is attached or all platforms have customContent. See each platform's schema for media constraints. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: { type: string } + content: + type: string + description: Post caption/text. Optional when media is attached or all platforms have customContent. Required for text-only posts. + mediaItems: + type: array + items: + type: object + properties: + type: { type: string, enum: [image, video, gif, document] } + url: { type: string, format: uri } + platforms: + type: array + items: + type: object + properties: + platform: { type: string, example: twitter } + accountId: { type: string } + customContent: + type: string + description: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters). + customMedia: + type: array + items: + type: object + properties: + type: { type: string, enum: [image, video, gif, document] } + url: { type: string, format: uri } + scheduledFor: + type: string + format: date-time + description: Optional per-platform scheduled time override. When omitted, the top-level scheduledFor is used. + platformSpecificData: + oneOf: + - $ref: '#/components/schemas/TwitterPlatformData' + - $ref: '#/components/schemas/ThreadsPlatformData' + - $ref: '#/components/schemas/FacebookPlatformData' + - $ref: '#/components/schemas/InstagramPlatformData' + - $ref: '#/components/schemas/LinkedInPlatformData' + - $ref: '#/components/schemas/PinterestPlatformData' + - $ref: '#/components/schemas/YouTubePlatformData' + - $ref: '#/components/schemas/GoogleBusinessPlatformData' + - $ref: '#/components/schemas/TikTokPlatformData' + - $ref: '#/components/schemas/TelegramPlatformData' + - $ref: '#/components/schemas/SnapchatPlatformData' + - $ref: '#/components/schemas/RedditPlatformData' + - $ref: '#/components/schemas/BlueskyPlatformData' + scheduledFor: { type: string, format: date-time } + publishNow: { type: boolean, default: false } + isDraft: + type: boolean + default: false + description: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically. + timezone: { type: string, default: UTC } + tags: + type: array + description: "Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed." + items: { type: string } + hashtags: + type: array + items: { type: string } + mentions: + type: array + items: { type: string } + crosspostingEnabled: { type: boolean, default: true } + metadata: { type: object, additionalProperties: true } + tiktokSettings: + $ref: '#/components/schemas/TikTokPlatformData' + description: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. + recycling: + $ref: '#/components/schemas/RecyclingConfig' + queuedFromProfile: + type: string + description: Profile ID to schedule via queue. When provided without scheduledFor, the post is auto-assigned to the next available slot. Do not call /v1/queue/next-slot and use that time in scheduledFor, as that bypasses queue locking. + queueId: + type: string + description: | + Specific queue ID to use when scheduling via queue. + Only used when queuedFromProfile is also provided. + If omitted, uses the profile's default queue. + examples: + recyclingPost: + summary: Post with weekly recycling and content variations + value: + content: "Check out our evergreen guide!" + platforms: + - platform: twitter + accountId: "64e1f0a9e2b5af0012ab34cd" + scheduledFor: "2025-06-01T10:00:00Z" + recycling: + gap: 2 + gapFreq: week + expireCount: 6 + contentVariations: + - "Check out our evergreen guide!" + - "Don't miss our essential guide!" + - "Our most popular guide, updated!" + tiktokPhotoCarousel: + summary: TikTok photo carousel with draft mode + value: + content: "Check out these photos!" + mediaItems: + - type: image + url: "https://example.com/photo1.jpg" + - type: image + url: "https://example.com/photo2.jpg" + platforms: + - platform: tiktok + accountId: "64e1f0a9e2b5af0012ab34cd" + tiktokSettings: + draft: true + privacyLevel: "PUBLIC_TO_EVERYONE" + allowComment: true + contentPreviewConfirmed: true + expressConsentGiven: true + tiktokVideo: + summary: TikTok video post (direct publish) + value: + content: "New video is live!" + mediaItems: + - type: video + url: "https://example.com/video.mp4" + platforms: + - platform: tiktok + accountId: "64e1f0a9e2b5af0012ab34cd" + tiktokSettings: + privacyLevel: "PUBLIC_TO_EVERYONE" + allowComment: true + allowDuet: true + allowStitch: true + commercialContentType: "none" + contentPreviewConfirmed: true + expressConsentGiven: true + multiPlatform: + summary: Multi-platform post (Twitter + LinkedIn) + value: + content: "We just launched our new product!" + mediaItems: + - type: image + url: "https://example.com/launch.jpg" + platforms: + - platform: twitter + accountId: "64e1f0a9e2b5af0012ab34cd" + - platform: linkedin + accountId: "64e1f0a9e2b5af0012ab34ef" + scheduledFor: "2024-11-01T10:00:00Z" + timezone: "America/New_York" + responses: + '201': + description: Post created + content: + application/json: + schema: + $ref: '#/components/schemas/PostCreateResponse' + examples: + scheduled: + summary: Scheduled post (URLs populated after publish time) + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + title: "Launch post" + content: "We just launched!" + status: "scheduled" + scheduledFor: "2024-11-01T10:00:00Z" + timezone: "UTC" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0..." + platform: "twitter" + username: "@acme" + displayName: "Acme Corp" + isActive: true + status: "pending" + message: "Post scheduled successfully" + immediatePublish: + summary: Immediate post with publishNow=true (URLs included) + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + title: "Launch post" + content: "We just launched!" + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + timezone: "UTC" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0a9e2b5af0012ab34de" + platform: "twitter" + username: "@acmecorp" + displayName: "Acme Corporation" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + platformPostId: "1852634789012345678" + platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678" + - platform: "linkedin" + accountId: + _id: "64e1f0a9e2b5af0012ab34ef" + platform: "linkedin" + username: "acme-corporation" + displayName: "Acme Corporation" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:06Z" + platformPostId: "urn:li:share:7123456789012345678" + platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678" + message: "Post published successfully" + queueScheduled: + summary: Post scheduled via queue (using queuedFromProfile) + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + content: "Scheduled via queue!" + status: "scheduled" + scheduledFor: "2024-11-01T09:00:00Z" + timezone: "America/New_York" + queuedFromProfile: "64f0a1b2c3d4e5f6a7b8c9d0" + queueId: "64f0a1b2c3d4e5f6a7b8c9d1" + platforms: + - platform: "linkedin" + accountId: + _id: "64e1f0..." + platform: "linkedin" + username: "acme-corp" + displayName: "Acme Corp" + isActive: true + status: "pending" + message: "Post scheduled successfully" + '400': + description: Validation error + content: + application/json: + schema: + type: object + properties: + error: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + error: { type: string } + '409': + description: Duplicate content detected + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: "This exact content was already posted to this account within the last 24 hours." } + details: + type: object + properties: + accountId: { type: string } + platform: { type: string } + existingPostId: { type: string } + '429': + description: "Rate limit exceeded. Possible causes: API rate limit, velocity limit (15 posts/hour per account), account cooldown, or daily platform limits." + content: + application/json: + schema: + type: object + properties: + error: { type: string } + details: + type: object + description: Additional context about the rate limit + headers: + Retry-After: + description: Seconds until the rate limit resets (for API rate limits) + schema: { type: integer } + X-RateLimit-Limit: + description: The rate limit ceiling + schema: { type: integer } + X-RateLimit-Remaining: + description: Requests remaining in current window + schema: { type: integer } + /v1/posts/{postId}: + get: + operationId: getPost + tags: [Posts] + summary: Get post + description: | + Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform. + parameters: + - name: postId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Post + content: + application/json: + schema: + $ref: '#/components/schemas/PostGetResponse' + examples: + scheduledPost: + summary: Scheduled post (pending) + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + title: "Launch post" + content: "We just launched!" + status: "scheduled" + scheduledFor: "2024-11-01T10:00:00Z" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0..." + platform: "twitter" + username: "@acme" + displayName: "Acme Corp" + isActive: true + status: "pending" + publishedPost: + summary: Published post with platformPostUrl + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + title: "Launch post" + content: "We just launched!" + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0a9e2b5af0012ab34de" + platform: "twitter" + username: "@acmecorp" + displayName: "Acme Corporation" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + platformPostId: "1852634789012345678" + platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678" + - platform: "linkedin" + accountId: + _id: "64e1f0a9e2b5af0012ab34ef" + platform: "linkedin" + username: "acme-corporation" + displayName: "Acme Corporation" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:06Z" + platformPostId: "urn:li:share:7123456789012345678" + platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678" + failedPost: + summary: Failed post with error details + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + content: "This post failed to publish" + status: "failed" + platforms: + - platform: "instagram" + accountId: + _id: "64e1f0a9e2b5af0012ab34de" + platform: "instagram" + username: "acmecorp" + isActive: false + status: "failed" + errorMessage: "Instagram access token has expired. Please reconnect your account." + errorCategory: "auth_expired" + errorSource: "user" + partialPost: + summary: Partial success (some platforms failed) + value: + post: + _id: "65f1c0a9e2b5af0012ab34cd" + content: "Launch announcement!" + status: "partial" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0a9e2b5af0012ab34de" + platform: "twitter" + username: "@acmecorp" + isActive: true + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + platformPostId: "1852634789012345678" + platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678" + - platform: "threads" + accountId: + _id: "64e1f0a9e2b5af0012ab34ef" + platform: "threads" + username: "acmecorp" + isActive: true + status: "failed" + errorMessage: "Post text exceeds the 500 character limit for Threads." + errorCategory: "user_content" + errorSource: "user" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + '404': { $ref: '#/components/responses/NotFound' } + put: + operationId: updatePost + tags: [Posts] + summary: Update post + description: | + Update an existing post. Only draft, scheduled, failed, and partial posts can be edited. + Published, publishing, and cancelled posts cannot be modified. + parameters: + - name: postId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: { type: string } + scheduledFor: { type: string, format: date-time } + tiktokSettings: + $ref: '#/components/schemas/TikTokPlatformData' + description: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. + recycling: + $ref: '#/components/schemas/RecyclingConfig' + additionalProperties: true + example: + content: "Updated content for our launch post!" + scheduledFor: "2024-11-02T14:00:00Z" + responses: + '200': + description: Post updated + content: + application/json: + schema: + $ref: '#/components/schemas/PostUpdateResponse' + example: + message: "Post updated successfully" + post: + _id: "65f1c0a9e2b5af0012ab34cd" + content: "Updated content for our launch post!" + status: "scheduled" + scheduledFor: "2024-11-02T14:00:00Z" + '207': + description: Partial publish success + '400': + description: Invalid request + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deletePost + tags: [Posts] + summary: Delete post + description: Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded. + parameters: + - name: postId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/PostDeleteResponse' + example: + message: "Post deleted successfully" + '400': + description: Cannot delete published posts + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + '404': { $ref: '#/components/responses/NotFound' } + /v1/posts/bulk-upload: + post: + operationId: bulkUploadPosts + tags: [Posts] + summary: Bulk upload from CSV + description: Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts. + parameters: + - name: dryRun + in: query + schema: { type: boolean, default: false } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Bulk upload results + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + totalRows: { type: integer } + created: { type: integer } + failed: { type: integer } + errors: + type: array + items: + type: object + properties: + row: { type: integer } + error: { type: string } + posts: + type: array + items: { $ref: '#/components/schemas/Post' } + example: + success: true + totalRows: 10 + created: 8 + failed: 2 + errors: + - row: 3 + error: "Invalid date format" + - row: 7 + error: "Account not found" + posts: + - _id: "65f1c0a9e2b5af0012ab34cd" + content: "First bulk post" + status: "scheduled" + scheduledFor: "2024-11-01T10:00:00Z" + '207': + description: Partial success + '400': + description: Invalid CSV or validation errors + '401': { $ref: '#/components/responses/Unauthorized' } + '429': + description: | + Rate limit exceeded. Possible causes: API rate limit (requests per minute) or account cooldown (one or more accounts for platforms specified in the CSV are temporarily rate-limited). + content: + application/json: + schema: + type: object + properties: + error: { type: string } + details: + type: object + /v1/posts/{postId}/retry: + post: + operationId: retryPost + tags: [Posts] + summary: Retry failed post + description: Immediately retries publishing a failed post. Returns the updated post with its new status. + parameters: + - name: postId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Retry successful + content: + application/json: + schema: + $ref: '#/components/schemas/PostRetryResponse' + example: + message: "Post published successfully" + post: + _id: "65f1c0a9e2b5af0012ab34cd" + content: "Check out our new product!" + status: "published" + publishedAt: "2024-11-01T10:00:05Z" + platforms: + - platform: "twitter" + accountId: + _id: "64e1f0..." + platform: "twitter" + username: "@acme" + displayName: "Acme Corp" + isActive: true + status: "published" + platformPostId: "1234567890" + platformPostUrl: "https://twitter.com/acme/status/1234567890" + '207': + description: Partial success + '400': + description: Invalid state + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: Post is currently publishing + '429': + description: | + Rate limit exceeded. Possible causes: API rate limit (requests per minute), velocity limit (15 posts/hour per account), or account cooldown (temporarily rate-limited due to repeated errors). + content: + application/json: + schema: + type: object + properties: + error: { type: string } + details: + type: object + /v1/posts/{postId}/unpublish: + post: + operationId: unpublishPost + tags: [Posts] + summary: Unpublish post + description: | + Deletes a published post from the specified platform. The post record in Zernio is kept but its status is updated to cancelled. + Not supported on Instagram, TikTok, or Snapchat. Threaded posts delete all items. YouTube deletion is permanent. + parameters: + - name: postId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [platform] + properties: + platform: + type: string + description: The platform to delete the post from + enum: + - threads + - facebook + - twitter + - linkedin + - youtube + - pinterest + - reddit + - bluesky + - googlebusiness + - telegram + example: + platform: "threads" + responses: + '200': + description: Post deleted from platform + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: true + message: "Post deleted from threads successfully" + '400': + description: "Invalid request: platform not supported for deletion, post not on that platform, not published, no platform post ID, or no access token." + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + '404': { $ref: '#/components/responses/NotFound' } + '500': + description: Platform API deletion failed + /v1/users: + get: + operationId: listUsers + tags: [Users] + summary: List users + description: Returns all users in the workspace including roles and profile access. Also returns the currentUserId of the caller. + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + currentUserId: { type: string } + users: + type: array + items: + type: object + properties: + _id: { type: string } + name: { type: string } + email: { type: string } + role: { type: string } + isRoot: { type: boolean } + profileAccess: + type: array + items: { type: string } + createdAt: { type: string, format: date-time } + example: + currentUserId: "6507a1b2c3d4e5f6a7b8c9d0" + users: + - _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "John Doe" + email: "john@example.com" + role: "owner" + isRoot: true + profileAccess: ["all"] + createdAt: "2024-01-15T10:30:00Z" + - _id: "6507a1b2c3d4e5f6a7b8c9d1" + name: "Jane Smith" + email: "jane@example.com" + role: "member" + isRoot: false + profileAccess: + - "64f0a1b2c3d4e5f6a7b8c9d0" + - "64f0a1b2c3d4e5f6a7b8c9d1" + createdAt: "2024-03-20T14:45:00Z" + '401': { $ref: '#/components/responses/Unauthorized' } + /v1/users/{userId}: + get: + operationId: getUser + tags: [Users] + summary: Get user + description: Returns a single user's details by ID, including name, email, and role. + parameters: + - name: userId + in: path + required: true + schema: { type: string } + responses: + '200': + description: User + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + _id: { type: string } + name: { type: string } + email: { type: string } + role: { type: string } + isRoot: { type: boolean } + profileAccess: + type: array + items: { type: string } + example: + user: + _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "John Doe" + email: "john@example.com" + role: "owner" + isRoot: true + profileAccess: ["all"] + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + '404': { $ref: '#/components/responses/NotFound' } + /v1/profiles: + get: + operationId: listProfiles + tags: [Profiles] + summary: List profiles + description: Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit. + parameters: + - name: includeOverLimit + in: query + required: false + schema: + type: boolean + default: false + description: "When true, includes over-limit profiles (marked with isOverLimit: true)." + responses: + '200': + description: Profiles + content: + application/json: + schema: + $ref: '#/components/schemas/ProfilesListResponse' + examples: + example: + value: + profiles: + - _id: "64f0..." + name: "Personal Brand" + color: "#ffeda0" + isDefault: true + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: createProfile + tags: [Profiles] + summary: Create profile + description: Creates a new profile with a name, optional description, and color. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + description: { type: string } + color: { type: string, example: '#ffeda0' } + example: + name: "Marketing Team" + description: "Profile for marketing campaigns" + color: "#4CAF50" + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileCreateResponse' + example: + message: "Profile created successfully" + profile: + _id: "64f0a1b2c3d4e5f6a7b8c9d0" + userId: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Marketing Team" + description: "Profile for marketing campaigns" + color: "#4CAF50" + isDefault: false + createdAt: "2024-11-01T10:00:00Z" + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: Profile limit exceeded } + /v1/profiles/{profileId}: + get: + operationId: getProfile + tags: [Profiles] + summary: Get profile + description: Returns a single profile by ID, including its name, color, and default status. + parameters: + - name: profileId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Profile + content: + application/json: + schema: + type: object + properties: + profile: { $ref: '#/components/schemas/Profile' } + example: + profile: + _id: "64f0a1b2c3d4e5f6a7b8c9d0" + userId: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Marketing Team" + description: "Profile for marketing campaigns" + color: "#4CAF50" + isDefault: false + createdAt: "2024-11-01T10:00:00Z" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + operationId: updateProfile + tags: [Profiles] + summary: Update profile + description: Updates a profile's name, description, color, or default status. + parameters: + - name: profileId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + description: { type: string } + color: { type: string } + isDefault: { type: boolean } + example: + name: "Marketing Team (Updated)" + color: "#2196F3" + isDefault: true + responses: + '200': + description: Updated + content: + application/json: + schema: + type: object + properties: + message: { type: string } + profile: { $ref: '#/components/schemas/Profile' } + example: + message: "Profile updated successfully" + profile: + _id: "64f0a1b2c3d4e5f6a7b8c9d0" + userId: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Marketing Team (Updated)" + description: "Profile for marketing campaigns" + color: "#2196F3" + isDefault: true + createdAt: "2024-11-01T10:00:00Z" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteProfile + tags: [Profiles] + summary: Delete profile + description: Permanently deletes a profile by ID. + parameters: + - name: profileId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Deleted + content: + application/json: + schema: + type: object + properties: + message: { type: string } + example: + message: "Profile deleted successfully" + '400': { description: Has connected accounts } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: Forbidden } + '404': { $ref: '#/components/responses/NotFound' } + /v1/accounts: + get: + operationId: listAccounts + tags: [Accounts] + summary: List accounts + description: Returns connected social accounts. Only includes accounts within the plan limit by default. Follower data requires analytics add-on. + parameters: + - name: profileId + in: query + schema: { type: string } + description: Filter accounts by profile ID + - name: platform + in: query + schema: { type: string } + description: Filter accounts by platform (e.g. "instagram", "twitter"). + - name: includeOverLimit + in: query + required: false + schema: + type: boolean + default: false + description: When true, includes accounts from over-limit profiles. + responses: + '200': + description: Accounts + content: + application/json: + schema: + type: object + properties: + accounts: + type: array + items: { $ref: '#/components/schemas/SocialAccount' } + hasAnalyticsAccess: + type: boolean + description: Whether user has analytics add-on access + examples: + example: + value: + accounts: + - _id: "64e1..." + platform: "twitter" + profileId: + _id: "64f0..." + name: "My Brand" + slug: "my-brand" + username: "@acme" + displayName: "Acme" + profileUrl: "https://x.com/acme" + isActive: true + hasAnalyticsAccess: false + '401': { $ref: '#/components/responses/Unauthorized' } + /v1/accounts/follower-stats: + get: + operationId: getFollowerStats + tags: [Accounts, Analytics] + summary: Get follower stats + description: | + Returns follower count history and growth metrics for connected social accounts. + Requires analytics add-on subscription. Follower counts are refreshed once per day. + parameters: + - name: accountIds + in: query + schema: { type: string } + description: Comma-separated list of account IDs (optional, defaults to all user's accounts) + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID + - name: fromDate + in: query + schema: { type: string, format: date } + description: Start date in YYYY-MM-DD format (defaults to 30 days ago) + - name: toDate + in: query + schema: { type: string, format: date } + description: End date in YYYY-MM-DD format (defaults to today) + - name: granularity + in: query + schema: { type: string, enum: [daily, weekly, monthly], default: daily } + description: Data aggregation level + responses: + '200': + description: Follower stats + content: + application/json: + schema: + type: object + properties: + accounts: + type: array + items: + $ref: '#/components/schemas/AccountWithFollowerStats' + stats: + type: object + additionalProperties: + type: array + items: + type: object + properties: + date: { type: string, format: date } + followers: { type: number } + dateRange: + type: object + properties: + from: { type: string, format: date-time } + to: { type: string, format: date-time } + granularity: { type: string } + examples: + example: + value: + accounts: + - _id: "64e1..." + platform: "twitter" + username: "@acme" + currentFollowers: 1250 + growth: 50 + growthPercentage: 4.17 + dataPoints: 30 + stats: + "64e1...": + - date: "2024-01-01" + followers: 1200 + - date: "2024-01-02" + followers: 1250 + dateRange: + from: "2024-01-01T00:00:00.000Z" + to: "2024-01-31T23:59:59.999Z" + granularity: "daily" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: Analytics add-on required } + message: { type: string, example: Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature. } + requiresAddon: { type: boolean, example: true } + /v1/accounts/{accountId}: + put: + operationId: updateAccount + tags: [Accounts] + summary: Update account + description: Updates a connected social account's display name or username override. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: { type: string } + displayName: { type: string } + example: + displayName: "Acme Corporation Official" + responses: + '200': + description: Updated + content: + application/json: + schema: + type: object + properties: + message: { type: string } + username: { type: string } + displayName: { type: string } + example: + message: "Account updated successfully" + username: "@acmecorp" + displayName: "Acme Corporation Official" + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteAccount + tags: [Accounts] + summary: Disconnect account + description: Disconnects and removes a connected social account. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Disconnected + content: + application/json: + schema: + type: object + properties: + message: { type: string } + example: + message: "Account disconnected successfully" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + /v1/accounts/health: + get: + operationId: getAllAccountsHealth + tags: [Accounts] + summary: Check accounts health + description: Returns health status of all connected accounts including token validity, permissions, and issues needing attention. + parameters: + - name: profileId + in: query + description: Filter by profile ID + schema: { type: string } + - name: platform + in: query + description: Filter by platform + schema: + type: string + enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat] + - name: status + in: query + description: Filter by health status + schema: + type: string + enum: [healthy, warning, error] + responses: + '200': + description: Account health summary + content: + application/json: + schema: + type: object + properties: + summary: + type: object + properties: + total: { type: integer, description: Total number of accounts } + healthy: { type: integer, description: Number of healthy accounts } + warning: { type: integer, description: Number of accounts with warnings } + error: { type: integer, description: Number of accounts with errors } + needsReconnect: { type: integer, description: Number of accounts needing reconnection } + accounts: + type: array + items: + type: object + properties: + accountId: { type: string } + platform: { type: string } + username: { type: string } + displayName: { type: string } + profileId: { type: string } + status: { type: string, enum: [healthy, warning, error] } + canPost: { type: boolean } + canFetchAnalytics: { type: boolean } + tokenValid: { type: boolean } + tokenExpiresAt: { type: string, format: date-time } + needsReconnect: { type: boolean } + issues: { type: array, items: { type: string } } + example: + summary: + total: 5 + healthy: 3 + warning: 1 + error: 1 + needsReconnect: 1 + accounts: + - accountId: "abc123" + platform: "instagram" + username: "myaccount" + status: "healthy" + canPost: true + canFetchAnalytics: true + tokenValid: true + tokenExpiresAt: "2025-06-15T00:00:00Z" + needsReconnect: false + issues: [] + - accountId: "def456" + platform: "twitter" + username: "mytwitter" + status: "error" + canPost: false + canFetchAnalytics: false + tokenValid: false + needsReconnect: true + issues: ["Token expired"] + '401': { $ref: '#/components/responses/Unauthorized' } + /v1/accounts/{accountId}/health: + get: + operationId: getAccountHealth + tags: [Accounts] + summary: Check account health + description: Returns detailed health info for a specific account including token status, permissions, and recommendations. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + description: The account ID to check + responses: + '200': + description: Account health details + content: + application/json: + schema: + type: object + properties: + accountId: { type: string } + platform: { type: string } + username: { type: string } + displayName: { type: string } + status: + type: string + enum: [healthy, warning, error] + description: Overall health status + tokenStatus: + type: object + properties: + valid: { type: boolean, description: Whether the token is valid } + expiresAt: { type: string, format: date-time } + expiresIn: { type: string, description: Human-readable time until expiry } + needsRefresh: { type: boolean, description: Whether token expires within 24 hours } + permissions: + type: object + properties: + posting: + type: array + items: + type: object + properties: + scope: { type: string } + granted: { type: boolean } + required: { type: boolean } + analytics: + type: array + items: + type: object + properties: + scope: { type: string } + granted: { type: boolean } + required: { type: boolean } + optional: + type: array + items: + type: object + properties: + scope: { type: string } + granted: { type: boolean } + required: { type: boolean } + canPost: { type: boolean } + canFetchAnalytics: { type: boolean } + missingRequired: { type: array, items: { type: string } } + issues: + type: array + items: { type: string } + description: List of issues found + recommendations: + type: array + items: { type: string } + description: Actionable recommendations to fix issues + example: + accountId: "abc123" + platform: "instagram" + username: "myaccount" + displayName: "My Account" + status: "healthy" + tokenStatus: + valid: true + expiresAt: "2025-06-15T00:00:00Z" + expiresIn: "180 days" + needsRefresh: false + permissions: + posting: + - scope: "instagram_basic" + granted: true + required: true + - scope: "instagram_content_publish" + granted: true + required: true + analytics: + - scope: "instagram_manage_insights" + granted: true + required: false + optional: [] + canPost: true + canFetchAnalytics: true + missingRequired: [] + issues: [] + recommendations: [] + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + /v1/api-keys: + get: + operationId: listApiKeys + tags: [API Keys] + summary: List keys + description: Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value. + responses: + '200': + description: API keys + content: + application/json: + schema: + type: object + properties: + apiKeys: + type: array + items: { $ref: '#/components/schemas/ApiKey' } + example: + apiKeys: + - id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Production API Key" + keyPreview: "sk_12345678...abcdef01" + expiresAt: "2025-12-31T23:59:59Z" + createdAt: "2024-01-15T10:30:00Z" + scope: "full" + profileIds: [] + permission: "read-write" + - id: "6507a1b2c3d4e5f6a7b8c9d1" + name: "Analytics Read-Only" + keyPreview: "sk_87654321...12345678" + expiresAt: null + createdAt: "2024-03-20T14:45:00Z" + scope: "profiles" + profileIds: + - _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Main Brand" + color: "#ffeda0" + permission: "read" + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: createApiKey + tags: [API Keys] + summary: Create key + description: Creates a new API key with an optional expiry. The full key value is only returned once in the response. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + expiresIn: + type: integer + description: Days until expiry + scope: + type: string + enum: [full, profiles] + description: "'full' grants access to all profiles (default), 'profiles' restricts to specific profiles" + default: full + profileIds: + type: array + items: { type: string } + description: Profile IDs this key can access. Required when scope is 'profiles'. + permission: + type: string + enum: [read-write, read] + description: "'read-write' allows all operations (default), 'read' restricts to GET requests only" + default: read-write + example: + name: "Analytics Read-Only Key" + scope: "profiles" + profileIds: ["6507a1b2c3d4e5f6a7b8c9d0"] + permission: "read" + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + properties: + message: { type: string } + apiKey: { $ref: '#/components/schemas/ApiKey' } + example: + message: "API key created successfully" + apiKey: + id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Analytics Read-Only Key" + keyPreview: "sk_12345678...90abcdef" + key: "sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + expiresAt: null + createdAt: "2024-01-15T10:30:00Z" + scope: "profiles" + profileIds: + - _id: "6507a1b2c3d4e5f6a7b8c9d0" + name: "Main Brand" + color: "#ffeda0" + permission: "read" + '400': { description: Invalid request (missing name, invalid scope/permission, or missing profileIds when scope is 'profiles') } + '401': { $ref: '#/components/responses/Unauthorized' } + /v1/api-keys/{keyId}: + delete: + operationId: deleteApiKey + tags: [API Keys] + summary: Delete key + description: Permanently revokes and deletes an API key. + parameters: + - name: keyId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Deleted + content: + application/json: + schema: + type: object + properties: + message: { type: string } + example: + message: "API key deleted successfully" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /v1/invite/tokens: + post: + operationId: createInviteToken + tags: [Invites] + summary: Create invite token + description: | + Generate a secure invite link to grant team members access to your profiles. + Invites expire after 7 days and are single-use. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [scope] + properties: + scope: + type: string + enum: [all, profiles] + description: "'all' grants access to all profiles, 'profiles' restricts to specific profiles" + profileIds: + type: array + items: { type: string } + description: Required if scope is 'profiles'. Array of profile IDs to grant access to. + example: + scope: "profiles" + profileIds: + - "64f0a1b2c3d4e5f6a7b8c9d0" + - "64f0a1b2c3d4e5f6a7b8c9d1" + responses: + '201': + description: Invite token created + content: + application/json: + schema: + type: object + properties: + token: { type: string } + scope: { type: string } + invitedProfileIds: + type: array + items: { type: string } + expiresAt: { type: string, format: date-time } + inviteUrl: { type: string, format: uri } + example: + token: "inv_abc123def456ghi789" + scope: "profiles" + invitedProfileIds: + - "64f0a1b2c3d4e5f6a7b8c9d0" + - "64f0a1b2c3d4e5f6a7b8c9d1" + expiresAt: "2024-11-08T10:30:00Z" + inviteUrl: "https://zernio.com/invite/inv_abc123def456ghi789" + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: One or more profiles not found or not owned } + + /v1/connect/{platform}: + get: + operationId: getConnectUrl + tags: [Connect] + summary: Get OAuth connect URL + description: | + Initiate an OAuth connection flow. Returns an authUrl to redirect the user to. + Standard flow: Zernio hosts the selection UI, then redirects to your redirect_url. Headless mode (headless=true): user is redirected to your redirect_url with OAuth data for custom UI. Use the platform-specific selection endpoints to complete. + parameters: + - name: platform + in: path + required: true + schema: + type: string + enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, reddit, pinterest, bluesky, googlebusiness, telegram, snapchat] + description: Social media platform to connect + - name: profileId + in: query + required: true + schema: { type: string } + description: Your Zernio profile ID (get from /v1/profiles) + - name: redirect_url + in: query + schema: { type: string, format: uri } + description: Your custom redirect URL after connection completes. Standard mode appends ?connected={platform}&profileId=X&accountId=Y&username=Z. Headless mode appends OAuth data params for platforms requiring selection (e.g. LinkedIn orgs, Facebook pages). If no selection is needed, the account is created directly and the redirect includes accountId. + - name: headless + in: query + schema: { type: boolean, default: false } + description: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience. + security: + - bearerAuth: [] + responses: + '200': + description: OAuth authorization URL to redirect user to + content: + application/json: + schema: + type: object + properties: + authUrl: + type: string + format: uri + description: URL to redirect your user to for OAuth authorization + state: + type: string + description: State parameter for security (handled automatically) + example: + authUrl: "https://www.facebook.com/v21.0/dialog/oauth?client_id=..." + state: "user123-profile456-1234567890-https://yourdomain.com/callback" + '400': + description: "Missing/invalid parameters (e.g., invalid profileId format)" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: "No access to profile, or BYOK required for AppSumo Twitter" + '404': + description: Profile not found + post: + operationId: handleOAuthCallback + tags: [Connect] + summary: Complete OAuth callback + description: Exchange the OAuth authorization code for tokens and connect the account to the specified profile. + parameters: + - name: platform + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code, state, profileId] + properties: + code: { type: string } + state: { type: string } + profileId: { type: string } + responses: + '200': { description: Account connected } + '400': { description: Invalid params } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: BYOK required for AppSumo Twitter } + '500': { description: Failed to connect account } + + /v1/connect/facebook/select-page: + get: + operationId: listFacebookPages + tags: [Connect] + summary: List Facebook pages + description: Returns the list of Facebook Pages the user can manage after OAuth. Extract tempToken and userProfile from the OAuth redirect params and pass them here. Use the X-Connect-Token header if connecting via API key. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + description: Profile ID from your connection flow + - name: tempToken + in: query + required: true + schema: { type: string } + description: Temporary Facebook access token from the OAuth callback redirect + security: + - bearerAuth: [] + - connectToken: [] + responses: + '200': + description: List of Facebook Pages available for connection + content: + application/json: + schema: + type: object + properties: + pages: + type: array + items: + type: object + properties: + id: { type: string, description: Facebook Page ID } + name: { type: string, description: Page name } + username: { type: string, description: Page username/handle (may be null) } + access_token: { type: string, description: Page-specific access token } + category: { type: string, description: Page category } + tasks: { type: array, items: { type: string }, description: User permissions for this page } + example: + pages: + - id: "123456789" + name: "My Brand Page" + username: "mybrand" + access_token: "EAAxxxxx..." + category: "Brand" + tasks: ["MANAGE", "CREATE_CONTENT"] + '400': { description: Missing required parameters (profileId or tempToken) } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': + description: Failed to fetch pages (e.g., invalid token, insufficient permissions) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + post: + operationId: selectFacebookPage + tags: [Connect] + summary: Select Facebook page + description: Complete the headless flow by saving the user's selected Facebook page. Pass the userProfile from the OAuth redirect and use X-Connect-Token if connecting via API key. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, pageId, tempToken] + properties: + profileId: + type: string + description: Profile ID from your connection flow + pageId: + type: string + description: The Facebook Page ID selected by the user + tempToken: + type: string + description: Temporary Facebook access token from OAuth + userProfile: + type: object + description: Decoded user profile object from the OAuth callback + properties: + id: { type: string } + name: { type: string } + profilePicture: { type: string } + redirect_url: + type: string + format: uri + description: Optional custom redirect URL to return to after selection + example: + profileId: "507f1f77bcf86cd799439011" + pageId: "123456789" + tempToken: "EAAxxxxx..." + userProfile: + id: "987654321" + name: "John Doe" + profilePicture: "https://..." + redirect_url: "https://yourdomain.com/integrations/callback" + security: + - bearerAuth: [] + - connectToken: [] + responses: + '200': + description: Facebook Page connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + redirect_url: + type: string + description: Redirect URL if custom redirect_url was provided + account: + type: object + properties: + accountId: + type: string + description: ID of the created SocialAccount + platform: { type: string, enum: [facebook] } + username: { type: string } + displayName: { type: string } + profilePicture: { type: string } + isActive: { type: boolean } + selectedPageName: { type: string } + example: + message: "Facebook page connected successfully" + redirect_url: "https://yourdomain.com/integrations/callback?connected=facebook&profileId=507f1f77bcf86cd799439011&username=My+Brand+Page" + account: + accountId: "64e1f0a9e2b5af0012ab34cd" + platform: "facebook" + username: "mybrand" + displayName: "My Brand Page" + profilePicture: "https://..." + isActive: true + selectedPageName: "My Brand Page" + '400': + description: "Missing required fields (profileId, pageId, or tempToken)" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: User does not have access to the specified profile + '404': + description: Selected page not found in available pages + '500': + description: Failed to save Facebook connection + + /v1/connect/googlebusiness/locations: + get: + operationId: listGoogleBusinessLocations + tags: [Connect] + summary: List GBP locations + description: For headless flows. Returns the list of GBP locations the user can manage. Use X-Connect-Token if connecting via API key. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + description: Profile ID from your connection flow + - name: tempToken + in: query + required: true + schema: { type: string } + description: Temporary Google access token from the OAuth callback redirect + security: + - bearerAuth: [] + - connectToken: [] + responses: + '200': + description: List of Google Business locations available for connection + content: + application/json: + schema: + type: object + properties: + locations: + type: array + items: + type: object + properties: + id: { type: string, description: Location ID } + name: { type: string, description: Business name } + accountId: { type: string, description: Google Business Account ID } + accountName: { type: string, description: Account name } + address: { type: string, description: Business address } + category: { type: string, description: Business category } + example: + locations: + - id: "9281089117903930794" + name: "My Coffee Shop" + accountId: "accounts/113303573364907650416" + accountName: "My Business Account" + address: "123 Main St, City, Country" + category: "Coffee shop" + '400': { description: Missing required parameters (profileId or tempToken) } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': + description: Failed to fetch locations (e.g., invalid token, insufficient permissions) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + + /v1/connect/googlebusiness/select-location: + post: + operationId: selectGoogleBusinessLocation + tags: [Connect] + summary: Select GBP location + description: Complete the headless flow by saving the user's selected GBP location. Include userProfile from the OAuth redirect (contains refresh token). Use X-Connect-Token if connecting via API key. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, locationId, tempToken] + properties: + profileId: + type: string + description: Profile ID from your connection flow + locationId: + type: string + description: The Google Business location ID selected by the user + tempToken: + type: string + description: Temporary Google access token from OAuth + userProfile: + type: object + description: Decoded user profile from the OAuth callback. Contains the refresh token. Always include this field. + properties: + id: { type: string } + name: { type: string } + refreshToken: { type: string, description: Google refresh token for long-lived access } + tokenExpiresIn: { type: integer, description: Token expiration time in seconds } + scope: { type: string, description: Granted OAuth scopes } + redirect_url: + type: string + format: uri + description: Optional custom redirect URL to return to after selection + example: + profileId: "507f1f77bcf86cd799439011" + locationId: "9281089117903930794" + tempToken: "ya29.xxxxx..." + userProfile: + id: "113303573364907650416" + name: "John Doe" + refreshToken: "1//0gxxxxx..." + tokenExpiresIn: 3599 + scope: "https://www.googleapis.com/auth/business.manage" + redirect_url: "https://yourdomain.com/integrations/callback" + security: + - bearerAuth: [] + - connectToken: [] + responses: + '200': + description: Google Business location connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + redirect_url: + type: string + description: Redirect URL if custom redirect_url was provided + account: + type: object + properties: + accountId: + type: string + description: ID of the created SocialAccount + platform: { type: string, enum: [googlebusiness] } + username: { type: string } + displayName: { type: string } + isActive: { type: boolean } + selectedLocationName: { type: string } + selectedLocationId: { type: string } + example: + message: "Google Business location connected successfully" + redirect_url: "https://yourdomain.com/integrations/callback?connected=googlebusiness&profileId=507f1f77bcf86cd799439011&username=My+Coffee+Shop" + account: + accountId: "64e1f0a9e2b5af0012ab34cd" + platform: "googlebusiness" + username: "My Coffee Shop" + displayName: "My Coffee Shop" + isActive: true + selectedLocationName: "My Coffee Shop" + selectedLocationId: "9281089117903930794" + '400': + description: "Missing required fields (profileId, locationId, or tempToken)" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: User does not have access to the specified profile + '404': + description: Selected location not found in available locations + '500': + description: Failed to save Google Business connection + + /v1/accounts/{accountId}/gmb-reviews: + get: + operationId: getGoogleBusinessReviews + tags: [GMB Reviews] + summary: Get reviews + description: Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + description: The Zernio account ID (from /v1/accounts) + - name: locationId + in: query + schema: { type: string } + description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + - name: pageSize + in: query + schema: { type: integer, minimum: 1, maximum: 50, default: 50 } + description: Number of reviews to fetch per page (max 50) + - name: pageToken + in: query + schema: { type: string } + description: Pagination token from previous response + security: + - bearerAuth: [] + responses: + '200': + description: Reviews fetched successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + reviews: + type: array + items: + type: object + properties: + id: { type: string, description: Review ID } + name: { type: string, description: Full resource name } + reviewer: + type: object + properties: + displayName: { type: string } + profilePhotoUrl: { type: string, nullable: true } + isAnonymous: { type: boolean } + rating: { type: integer, minimum: 1, maximum: 5, description: Numeric star rating } + starRating: { type: string, enum: [ONE, TWO, THREE, FOUR, FIVE], description: Google's string rating } + comment: { type: string, description: Review text } + createTime: { type: string, format: date-time } + updateTime: { type: string, format: date-time } + reviewReply: + type: object + nullable: true + properties: + comment: { type: string, description: Business owner reply } + updateTime: { type: string, format: date-time } + averageRating: { type: number, description: Overall average rating } + totalReviewCount: { type: integer, description: Total number of reviews } + nextPageToken: { type: string, nullable: true, description: Token for next page } + example: + success: true + accountId: "64e1f0a9e2b5af0012ab34cd" + locationId: "9281089117903930794" + reviews: + - id: "AIe9_BGx1234567890" + name: "accounts/123456789/locations/9281089117903930794/reviews/AIe9_BGx1234567890" + reviewer: + displayName: "John Smith" + profilePhotoUrl: "https://lh3.googleusercontent.com/a/..." + isAnonymous: false + rating: 5 + starRating: "FIVE" + comment: "Great service and friendly staff! Highly recommend." + createTime: "2024-01-15T10:30:00Z" + updateTime: "2024-01-15T10:30:00Z" + reviewReply: + comment: "Thank you for your kind words! We appreciate your support." + updateTime: "2024-01-16T08:00:00Z" + - id: "AIe9_BGx0987654321" + name: "accounts/123456789/locations/9281089117903930794/reviews/AIe9_BGx0987654321" + reviewer: + displayName: "Anonymous" + profilePhotoUrl: null + isAnonymous: true + rating: 4 + starRating: "FOUR" + comment: "Good experience overall." + createTime: "2024-01-10T14:20:00Z" + updateTime: "2024-01-10T14:20:00Z" + reviewReply: null + averageRating: 4.5 + totalReviewCount: 125 + nextPageToken: "CiAKHAoUMTIzNDU2Nzg5" + '400': + description: Invalid request - not a Google Business account or missing location + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "This endpoint is only available for Google Business Profile accounts" + '401': + description: Unauthorized or token invalid + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "Access token invalid. Please reconnect your Google Business Profile account." + code: "token_invalid" + '403': + description: Permission denied for this location + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "You do not have permission to access reviews for this location." + '404': { $ref: '#/components/responses/NotFound' } + '500': + description: Failed to fetch reviews + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /v1/accounts/{accountId}/gmb-food-menus: + get: + operationId: getGoogleBusinessFoodMenus + tags: [GMB Food Menus] + summary: Get food menus + description: Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + description: The Zernio account ID (from /v1/accounts) + - name: locationId + in: query + schema: { type: string } + description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + responses: + '200': + description: Food menus fetched successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + name: { type: string, description: Resource name of the food menus } + menus: + type: array + items: + $ref: '#/components/schemas/FoodMenu' + example: + success: true + accountId: "64e1f0a9e2b5af0012ab34cd" + locationId: "9281089117903930794" + name: "accounts/123456789/locations/9281089117903930794/foodMenus" + menus: + - labels: + - displayName: "Lunch Menu" + description: "Available 11am-3pm" + languageCode: "en" + sections: + - labels: + - displayName: "Appetizers" + items: + - labels: + - displayName: "Caesar Salad" + description: "Romaine, parmesan, croutons" + attributes: + price: + currencyCode: "USD" + units: "12" + dietaryRestriction: ["VEGETARIAN"] + '400': + description: Invalid request - not a Google Business account or missing location + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "This endpoint is only available for Google Business Profile accounts" + '401': + description: Unauthorized or token invalid + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "Access token invalid. Please reconnect your Google Business Profile account." + code: "token_invalid" + '403': + description: Permission denied for this location + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "You do not have permission to access food menus for this location." + '404': { $ref: '#/components/responses/NotFound' } + '500': + description: Failed to fetch food menus + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + put: + operationId: updateGoogleBusinessFoodMenus + tags: [GMB Food Menus] + summary: Update food menus + description: Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + description: The Zernio account ID (from /v1/accounts) + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [menus] + properties: + menus: + type: array + items: + $ref: '#/components/schemas/FoodMenu' + description: Array of food menus to set + updateMask: + type: string + description: Field mask for partial updates (e.g. "menus") + example: + menus: + - labels: + - displayName: "Dinner Menu" + languageCode: "en" + sections: + - labels: + - displayName: "Mains" + items: + - labels: + - displayName: "Grilled Salmon" + description: "With seasonal vegetables" + attributes: + price: + currencyCode: "USD" + units: "24" + allergen: ["FISH"] + updateMask: "menus" + responses: + '200': + description: Food menus updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + name: { type: string } + menus: + type: array + items: + $ref: '#/components/schemas/FoodMenu' + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + error: "Request body must include a \"menus\" array" + '401': + description: Unauthorized or token expired + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '403': + description: Permission denied for this location + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '404': { $ref: '#/components/responses/NotFound' } + '500': + description: Failed to update food menus + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /v1/accounts/{accountId}/gmb-location-details: + get: + operationId: getGoogleBusinessLocationDetails + tags: [GMB Location Details] + summary: Get location details + description: Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + description: The Zernio account ID (from /v1/accounts) + - name: locationId + in: query + schema: { type: string } + description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + - name: readMask + in: query + required: false + schema: { type: string } + description: "Comma-separated fields to return. Available: name, title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, serviceArea, serviceItems, profile, openInfo, metadata, moreHours." + security: + - bearerAuth: [] + responses: + '200': + description: Location details fetched successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + title: { type: string, description: Business name } + regularHours: + type: object + properties: + periods: + type: array + items: + type: object + properties: + openDay: { type: string, enum: [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY] } + openTime: { type: string, description: "Opening time in HH:MM format" } + closeDay: { type: string } + closeTime: { type: string } + specialHours: + type: object + properties: + specialHourPeriods: + type: array + items: + type: object + properties: + startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } } + endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } } + openTime: { type: string } + closeTime: { type: string } + closed: { type: boolean } + profile: + type: object + properties: + description: { type: string, description: Business description } + websiteUri: { type: string } + phoneNumbers: + type: object + properties: + primaryPhone: { type: string } + additionalPhones: { type: array, items: { type: string } } + categories: + type: object + description: "Business categories (returned when readMask includes 'categories')" + properties: + primaryCategory: + type: object + properties: + name: { type: string, description: "Category resource name" } + displayName: { type: string, description: "Human-readable category name" } + additionalCategories: + type: array + items: + type: object + properties: + name: { type: string } + displayName: { type: string } + serviceItems: + type: array + description: "Services offered (returned when readMask includes 'serviceItems')" + items: + type: object + properties: + structuredServiceItem: + type: object + properties: + serviceTypeId: { type: string } + description: { type: string } + freeFormServiceItem: + type: object + properties: + category: { type: string } + label: + type: object + properties: + displayName: { type: string } + languageCode: { type: string } + price: + type: object + properties: + currencyCode: { type: string } + units: { type: string } + nanos: { type: integer } + example: + success: true + accountId: "64e1f0a9e2b5af0012ab34cd" + locationId: "9281089117903930794" + title: "Joe's Pizza" + regularHours: + periods: + - openDay: "MONDAY" + openTime: "11:00" + closeDay: "MONDAY" + closeTime: "22:00" + - openDay: "TUESDAY" + openTime: "11:00" + closeDay: "TUESDAY" + closeTime: "22:00" + specialHours: + specialHourPeriods: + - startDate: { year: 2026, month: 12, day: 25 } + closed: true + profile: + description: "Authentic New York style pizza since 1985" + websiteUri: "https://joespizza.com" + categories: + primaryCategory: + name: "categories/gcid:pizza_restaurant" + displayName: "Pizza restaurant" + additionalCategories: + - name: "categories/gcid:italian_restaurant" + displayName: "Italian restaurant" + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized or token expired + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '404': { $ref: '#/components/responses/NotFound' } + put: + operationId: updateGoogleBusinessLocationDetails + tags: [GMB Location Details] + summary: Update location details + description: | + Updates GBP location details. The updateMask field is required and specifies which fields to update. + This endpoint proxies Google's Business Information API locations.patch, so any valid updateMask field is supported. + Common fields: regularHours, specialHours, profile.description, websiteUri, phoneNumbers, categories, serviceItems. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + description: The Zernio account ID (from /v1/accounts) + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [updateMask] + properties: + updateMask: + type: string + description: "Required. Comma-separated fields to update (e.g. 'regularHours', 'specialHours', 'profile.description', 'categories', 'serviceItems'). Any valid Google Business Information API updateMask field is supported." + regularHours: + type: object + properties: + periods: + type: array + items: + type: object + properties: + openDay: { type: string } + openTime: { type: string } + closeDay: { type: string } + closeTime: { type: string } + specialHours: + type: object + properties: + specialHourPeriods: + type: array + items: + type: object + properties: + startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } } + endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } } + openTime: { type: string } + closeTime: { type: string } + closed: { type: boolean } + profile: + type: object + properties: + description: { type: string } + websiteUri: { type: string } + phoneNumbers: + type: object + properties: + primaryPhone: { type: string } + additionalPhones: { type: array, items: { type: string } } + categories: + type: object + description: "Primary and additional business categories. Use updateMask='categories' to update." + properties: + primaryCategory: + type: object + properties: + name: + type: string + description: "Category resource name (e.g. 'categories/gcid:laundromat'). Use Google's Categories API to look up valid IDs." + additionalCategories: + type: array + items: + type: object + properties: + name: + type: string + description: "Category resource name (e.g. 'categories/gcid:dry_cleaner')" + serviceItems: + type: array + description: "Services offered by the business. Use updateMask='serviceItems' to update." + items: + type: object + properties: + structuredServiceItem: + type: object + description: "A predefined service from Google's service type catalog" + properties: + serviceTypeId: + type: string + description: "Service type ID from Google's catalog (e.g. 'job_type_id:plumbing_drain_repair')" + description: + type: string + description: "Optional description of the service" + freeFormServiceItem: + type: object + description: "A custom service not in Google's catalog" + properties: + category: + type: string + description: "Category resource name this service belongs to (e.g. 'categories/gcid:laundromat')" + label: + type: object + properties: + displayName: + type: string + description: "Service name as displayed to users" + languageCode: + type: string + description: "Language code (e.g. 'en')" + price: + type: object + description: "Optional price for the service" + properties: + currencyCode: { type: string, description: "ISO 4217 currency code (e.g. 'USD')" } + units: { type: string, description: "Whole units of the amount" } + nanos: { type: integer, description: "Nano units (10^-9) of the amount" } + examples: + updateHours: + summary: Update business hours + value: + updateMask: "regularHours,specialHours" + regularHours: + periods: + - openDay: "MONDAY" + openTime: "09:00" + closeDay: "MONDAY" + closeTime: "17:00" + - openDay: "SATURDAY" + openTime: "10:00" + closeDay: "SATURDAY" + closeTime: "14:00" + specialHours: + specialHourPeriods: + - startDate: { year: 2026, month: 12, day: 25 } + closed: true + - startDate: { year: 2026, month: 12, day: 31 } + openTime: "09:00" + closeTime: "15:00" + updateCategories: + summary: Update business categories + value: + updateMask: "categories" + categories: + primaryCategory: + name: "categories/gcid:laundromat" + additionalCategories: + - name: "categories/gcid:dry_cleaner" + - name: "categories/gcid:laundry_service" + updateServices: + summary: Update service items + value: + updateMask: "serviceItems" + serviceItems: + - structuredServiceItem: + serviceTypeId: "job_type_id:plumbing_drain_repair" + description: "Full drain cleaning and repair service" + - freeFormServiceItem: + category: "categories/gcid:laundromat" + label: + displayName: "Wash & Fold Service" + languageCode: "en" + price: + currencyCode: "USD" + units: "25" + responses: + '200': + description: Location updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + '400': + description: Invalid request or missing updateMask + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized or token expired + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '404': { $ref: '#/components/responses/NotFound' } + + /v1/accounts/{accountId}/gmb-media: + get: + operationId: listGoogleBusinessMedia + tags: [GMB Media] + summary: List media + description: | + Lists media items (photos) for a Google Business Profile location. + Returns photo URLs, descriptions, categories, and metadata. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + - name: pageSize + in: query + schema: { type: integer, maximum: 100, default: 100 } + description: Number of items to return (max 100) + - name: pageToken + in: query + schema: { type: string } + description: Pagination token from previous response + security: + - bearerAuth: [] + responses: + '200': + description: Media items fetched successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + mediaItems: + type: array + items: + type: object + properties: + name: { type: string, description: Resource name } + mediaFormat: { type: string, enum: [PHOTO, VIDEO] } + sourceUrl: { type: string } + googleUrl: { type: string, description: Google-hosted URL } + thumbnailUrl: { type: string } + description: { type: string } + createTime: { type: string, format: date-time } + locationAssociation: + type: object + properties: + category: { type: string } + nextPageToken: { type: string } + totalMediaItemsCount: { type: integer } + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + post: + operationId: createGoogleBusinessMedia + tags: [GMB Media] + summary: Upload photo + description: | + Creates a media item (photo) for a location from a publicly accessible URL. + + Categories determine where the photo appears: COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceUrl] + properties: + sourceUrl: { type: string, description: Publicly accessible image URL } + mediaFormat: { type: string, enum: [PHOTO, VIDEO], default: PHOTO } + description: { type: string, description: Photo description } + category: + type: string + enum: [COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL] + description: Where the photo appears on the listing + example: + sourceUrl: "https://example.com/photos/restaurant-interior.jpg" + description: "Dining area with outdoor seating" + category: "INTERIOR" + responses: + '200': + description: Media created successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + name: { type: string } + mediaFormat: { type: string } + googleUrl: { type: string } + '400': + description: Invalid request or unsupported media format + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + delete: + operationId: deleteGoogleBusinessMedia + tags: [GMB Media] + summary: Delete photo + description: Deletes a photo or media item from a GBP location. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + - name: mediaId + in: query + required: true + schema: { type: string } + description: The media item ID to delete + security: + - bearerAuth: [] + responses: + '200': + description: Media deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + deleted: { type: boolean } + mediaId: { type: string } + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /v1/accounts/{accountId}/gmb-attributes: + get: + operationId: getGoogleBusinessAttributes + tags: [GMB Attributes] + summary: Get attributes + description: Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + responses: + '200': + description: Attributes fetched successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + attributes: + type: array + items: + type: object + properties: + name: { type: string, description: "Attribute identifier (e.g. has_delivery)" } + valueType: { type: string, description: "Value type (BOOL, ENUM, URL, REPEATED_ENUM)" } + values: { type: array, items: {} } + repeatedEnumValue: + type: object + properties: + setValues: { type: array, items: { type: string } } + unsetValues: { type: array, items: { type: string } } + example: + success: true + attributes: + - name: "has_delivery" + valueType: "BOOL" + values: [true] + - name: "has_takeout" + valueType: "BOOL" + values: [true] + - name: "has_outdoor_seating" + valueType: "BOOL" + values: [true] + - name: "pay_credit_card_types_accepted" + valueType: "REPEATED_ENUM" + repeatedEnumValue: + setValues: ["visa", "mastercard", "amex"] + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + put: + operationId: updateGoogleBusinessAttributes + tags: [GMB Attributes] + summary: Update attributes + description: | + Updates location attributes (amenities, services, etc.). + + The attributeMask specifies which attributes to update (comma-separated). + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [attributes, attributeMask] + properties: + attributes: + type: array + items: + type: object + properties: + name: { type: string } + values: { type: array, items: {} } + repeatedEnumValue: + type: object + properties: + setValues: { type: array, items: { type: string } } + unsetValues: { type: array, items: { type: string } } + attributeMask: + type: string + description: "Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout')" + example: + attributes: + - name: "has_delivery" + values: [true] + - name: "has_takeout" + values: [true] + - name: "has_outdoor_seating" + values: [false] + attributeMask: "has_delivery,has_takeout,has_outdoor_seating" + responses: + '200': + description: Attributes updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + attributes: { type: array, items: { type: object } } + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /v1/accounts/{accountId}/gmb-place-actions: + get: + operationId: listGoogleBusinessPlaceActions + tags: [GMB Place Actions] + summary: List action links + description: | + Lists place action links for a Google Business Profile location. + + Place actions are the booking, ordering, and reservation buttons that appear on your listing. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + - name: pageSize + in: query + schema: { type: integer, maximum: 100, default: 100 } + - name: pageToken + in: query + schema: { type: string } + security: + - bearerAuth: [] + responses: + '200': + description: Place actions fetched successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + accountId: { type: string } + locationId: { type: string } + placeActionLinks: + type: array + items: + type: object + properties: + name: { type: string, description: Resource name } + uri: { type: string, description: Action URL } + placeActionType: { type: string } + createTime: { type: string, format: date-time } + updateTime: { type: string, format: date-time } + nextPageToken: { type: string } + example: + success: true + placeActionLinks: + - name: "locations/123/placeActionLinks/456" + uri: "https://order.ubereats.com/joespizza" + placeActionType: "FOOD_ORDERING" + - name: "locations/123/placeActionLinks/789" + uri: "https://www.opentable.com/joespizza" + placeActionType: "DINING_RESERVATION" + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + post: + operationId: createGoogleBusinessPlaceAction + tags: [GMB Place Actions] + summary: Create action link + description: | + Creates a place action link for a location. + + Available action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [uri, placeActionType] + properties: + uri: { type: string, description: The action URL } + placeActionType: + type: string + enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE] + description: Type of action + example: + uri: "https://order.ubereats.com/joespizza" + placeActionType: "FOOD_ORDERING" + responses: + '200': + description: Place action created successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + name: { type: string, description: Resource name of the created link } + uri: { type: string } + placeActionType: { type: string } + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + delete: + operationId: deleteGoogleBusinessPlaceAction + tags: [GMB Place Actions] + summary: Delete action link + description: Deletes a place action link (e.g. booking or ordering URL) from a GBP location. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: locationId + in: query + schema: { type: string } + description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + - name: name + in: query + required: true + schema: { type: string } + description: "The resource name of the place action link (e.g. locations/123/placeActionLinks/456)" + security: + - bearerAuth: [] + responses: + '200': + description: Place action deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + deleted: { type: boolean } + name: { type: string } + '400': + description: Invalid request + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /v1/connect/pending-data: + get: + operationId: getPendingOAuthData + tags: [Connect] + summary: Get pending OAuth data + description: Fetch pending OAuth data for headless mode using the pendingDataToken from the redirect URL. One-time use, expires after 10 minutes. No authentication required. + parameters: + - name: token + in: query + required: true + schema: { type: string } + description: The pending data token from the OAuth redirect URL (pendingDataToken parameter) + responses: + '200': + description: OAuth data fetched successfully + content: + application/json: + schema: + type: object + properties: + platform: + type: string + description: The platform (e.g., "linkedin") + profileId: + type: string + description: The Zernio profile ID + tempToken: + type: string + description: Temporary access token for the platform + refreshToken: + type: string + description: Refresh token (if available) + expiresIn: + type: number + description: Token expiry in seconds + userProfile: + type: object + description: User profile data (id, username, displayName, profilePicture) + selectionType: + type: string + enum: [organizations, pages, boards, locations, profiles] + description: Type of selection data + organizations: + type: array + description: LinkedIn organizations (when selectionType is "organizations") + items: + type: object + properties: + id: { type: string } + urn: { type: string } + name: { type: string } + vanityName: { type: string } + example: + platform: "linkedin" + profileId: "abc123" + tempToken: "AQV..." + refreshToken: "AQW..." + expiresIn: 5183999 + userProfile: + id: "ABC123" + username: "John Doe" + displayName: "John Doe" + profilePicture: "https://..." + selectionType: "organizations" + organizations: + - id: "12345" + urn: "urn:li:organization:12345" + name: "Acme Corp" + vanityName: "acme-corp" + - id: "67890" + urn: "urn:li:organization:67890" + name: "Example Inc" + vanityName: "example-inc" + '400': + description: Missing token parameter + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + '404': + description: Token not found or expired + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /v1/connect/linkedin/organizations: + get: + operationId: listLinkedInOrganizations + tags: [Connect] + summary: List LinkedIn orgs + description: Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth. + parameters: + - name: tempToken + in: query + required: true + schema: { type: string } + description: The temporary LinkedIn access token from the OAuth redirect + - name: orgIds + in: query + required: true + schema: { type: string } + description: Comma-separated list of organization IDs to fetch details for (max 100) + example: "12345678,87654321,11111111" + responses: + '200': + description: Organization details fetched successfully + content: + application/json: + schema: + type: object + properties: + organizations: + type: array + items: + type: object + properties: + id: { type: string, description: Organization ID } + logoUrl: { type: string, format: uri, description: Logo URL (may be absent if no logo) } + vanityName: { type: string, description: Organization's vanity name/slug } + website: { type: string, format: uri, description: Organization's website URL } + industry: { type: string, description: Organization's primary industry } + description: { type: string, description: Organization's description } + example: + organizations: + - id: "12345678" + logoUrl: "https://media.licdn.com/dms/image/v2/..." + vanityName: "acme-corp" + website: "https://acme.com" + industry: "Technology" + description: "Leading provider of innovative solutions" + - id: "87654321" + logoUrl: "https://media.licdn.com/dms/image/v2/..." + vanityName: "example-inc" + website: "https://example.com" + - id: "11111111" + '400': + description: Missing required parameters or too many organization IDs + content: + application/json: + schema: + type: object + properties: + error: { type: string } + example: + error: "Missing tempToken parameter" + '500': + description: Failed to fetch organization details + + /v1/connect/linkedin/select-organization: + post: + operationId: selectLinkedInOrganization + tags: [Connect] + summary: Select LinkedIn org + description: Complete the LinkedIn connection flow. Set accountType to "personal" or "organization" to connect as a company page. Use X-Connect-Token if connecting via API key. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, tempToken, userProfile, accountType] + properties: + profileId: { type: string } + tempToken: { type: string } + userProfile: { type: object } + accountType: { type: string, enum: [personal, organization] } + selectedOrganization: { type: object } + redirect_url: { type: string, format: uri } + examples: + personalAccount: + summary: Connect as personal LinkedIn profile + description: For personal accounts, set accountType to "personal" and omit selectedOrganization + value: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + tempToken: "AQX..." + userProfile: + id: "abc123" + username: "johndoe" + displayName: "John Doe" + profilePicture: "https://media.licdn.com/dms/image/v2/..." + accountType: "personal" + organizationAccount: + summary: Connect as org/company page + description: For organization pages, include the selectedOrganization object + value: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + tempToken: "AQX..." + userProfile: + id: "abc123" + username: "johndoe" + displayName: "John Doe" + profilePicture: "https://media.licdn.com/dms/image/v2/..." + accountType: "organization" + selectedOrganization: + id: "12345678" + urn: "urn:li:organization:12345678" + name: "Acme Corporation" + redirect_url: "https://yourapp.com/callback" + responses: + '200': + description: LinkedIn account connected + content: + application/json: + schema: + type: object + properties: + message: { type: string } + redirect_url: + type: string + description: The redirect URL with connection params appended (only if redirect_url was provided in request) + account: + type: object + properties: + accountId: + type: string + description: ID of the created SocialAccount + platform: { type: string, enum: [linkedin] } + username: { type: string } + displayName: { type: string } + profilePicture: { type: string } + isActive: { type: boolean } + accountType: { type: string, enum: [personal, organization] } + bulkRefresh: + type: object + properties: + updatedCount: { type: integer } + errors: { type: integer } + examples: + personalAccountResponse: + summary: Personal account connected + value: + message: "LinkedIn account connected successfully" + account: + accountId: "64e1f0a9e2b5af0012ab34cd" + platform: "linkedin" + username: "johndoe" + displayName: "John Doe" + profilePicture: "https://media.licdn.com/..." + isActive: true + accountType: "personal" + organizationWithRedirect: + summary: Org account with redirect URL + value: + message: "LinkedIn account connected successfully" + redirect_url: "https://yourapp.com/callback?connected=linkedin&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&username=Acme+Corporation" + account: + accountId: "64e1f0a9e2b5af0012ab34cd" + platform: "linkedin" + username: "acme-corp" + displayName: "Acme Corporation" + profilePicture: "https://media.licdn.com/..." + isActive: true + accountType: "organization" + bulkRefresh: + updatedCount: 5 + errors: 0 + '400': { description: Missing required fields } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { description: Failed to connect LinkedIn account } + + /v1/connect/pinterest/select-board: + get: + operationId: listPinterestBoardsForSelection + tags: [Connect] + summary: List Pinterest boards + description: For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL. + parameters: + - name: X-Connect-Token + in: header + required: true + schema: { type: string } + description: Short-lived connect token from the OAuth redirect + - name: profileId + in: query + required: true + schema: { type: string } + description: Your Zernio profile ID + - name: tempToken + in: query + required: true + schema: { type: string } + description: Temporary Pinterest access token from the OAuth callback redirect + responses: + '200': + description: List of Pinterest Boards available for connection + content: + application/json: + schema: + type: object + properties: + boards: + type: array + items: + type: object + properties: + id: { type: string, description: Pinterest Board ID } + name: { type: string, description: Board name } + description: { type: string, description: Board description } + privacy: { type: string, description: Board privacy setting } + example: + boards: + - id: "123456789012345678" + name: "Marketing Ideas" + description: "Collection of marketing inspiration" + privacy: "PUBLIC" + - id: "234567890123456789" + name: "Product Photos" + description: "Product photography" + privacy: "PUBLIC" + '400': { description: Missing required parameters } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: No access to profile } + '500': { description: Failed to fetch boards } + post: + operationId: selectPinterestBoard + tags: [Connect] + summary: Select Pinterest board + description: | + Complete the Pinterest connection flow. After OAuth, use this endpoint to save the selected board and complete the account connection. Use the X-Connect-Token header if you initiated the connection via API key. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, boardId, tempToken] + properties: + profileId: + type: string + description: Your Zernio profile ID + boardId: + type: string + description: The Pinterest Board ID selected by the user + boardName: + type: string + description: The board name (for display purposes) + tempToken: + type: string + description: Temporary Pinterest access token from OAuth + userProfile: + type: object + description: User profile data from OAuth redirect + refreshToken: + type: string + description: Pinterest refresh token (if available) + expiresIn: + type: integer + description: Token expiration time in seconds + redirect_url: + type: string + format: uri + description: Custom redirect URL after connection completes + example: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + boardId: "123456789012345678" + boardName: "Marketing Ideas" + tempToken: "pina_..." + userProfile: + id: "user123" + username: "mybrand" + displayName: "My Brand" + profilePicture: "https://i.pinimg.com/..." + redirect_url: "https://yourapp.com/callback" + responses: + '200': + description: Pinterest Board connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + redirect_url: { type: string, description: Redirect URL with connection params (if provided) } + account: + type: object + properties: + accountId: + type: string + description: ID of the created SocialAccount + platform: { type: string, enum: [pinterest] } + username: { type: string } + displayName: { type: string } + profilePicture: { type: string } + isActive: { type: boolean } + defaultBoardName: { type: string } + example: + message: "Pinterest connected successfully with default board" + redirect_url: "https://yourdomain.com/integrations/callback?connected=pinterest&profileId=507f1f77bcf86cd799439011&board=Marketing+Ideas" + account: + accountId: "64e1f0a9e2b5af0012ab34cd" + platform: "pinterest" + username: "mybrand" + displayName: "My Brand" + profilePicture: "https://i.pinimg.com/..." + isActive: true + defaultBoardName: "Marketing Ideas" + '400': + description: Missing required fields + content: + application/json: + example: + error: "Missing required fields" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: No access to profile or profile limit exceeded + content: + application/json: + examples: + forbidden: + value: { error: "Forbidden" } + limitExceeded: + value: + error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles." + code: "PROFILE_LIMIT_EXCEEDED" + '500': + description: Failed to save Pinterest connection + + /v1/connect/snapchat/select-profile: + get: + operationId: listSnapchatProfiles + tags: [Connect] + summary: List Snapchat profiles + description: For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL. + parameters: + - name: X-Connect-Token + in: header + required: true + schema: { type: string } + description: Short-lived connect token from the OAuth redirect + - name: profileId + in: query + required: true + schema: { type: string } + description: Your Zernio profile ID + - name: tempToken + in: query + required: true + schema: { type: string } + description: Temporary Snapchat access token from the OAuth callback redirect + responses: + '200': + description: List of Snapchat Public Profiles available for connection + content: + application/json: + schema: + type: object + properties: + publicProfiles: + type: array + items: + type: object + properties: + id: { type: string, description: Snapchat Public Profile ID } + display_name: { type: string, description: Public profile display name } + username: { type: string, description: Public profile username/handle } + profile_image_url: { type: string, description: Profile image URL } + subscriber_count: { type: integer, description: Number of subscribers } + example: + publicProfiles: + - id: "abc123-def456" + display_name: "My Brand" + username: "mybrand" + profile_image_url: "https://cf-st.sc-cdn.net/..." + subscriber_count: 15000 + - id: "xyz789-uvw012" + display_name: "Side Project" + username: "sideproject" + profile_image_url: "https://cf-st.sc-cdn.net/..." + subscriber_count: 5000 + '400': { description: Missing required parameters (profileId or tempToken) } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: No access to profile } + '500': { description: Failed to fetch public profiles } + post: + operationId: selectSnapchatProfile + tags: [Connect] + summary: Select Snapchat profile + description: Complete the Snapchat connection flow by saving the selected Public Profile. Snapchat requires a Public Profile to publish content. Use X-Connect-Token if connecting via API key. + parameters: + - name: X-Connect-Token + in: header + required: false + schema: { type: string } + description: Short-lived connect token from the OAuth redirect (for API users) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, selectedPublicProfile, tempToken, userProfile] + properties: + profileId: + type: string + description: Your Zernio profile ID + selectedPublicProfile: + type: object + description: The selected Snapchat Public Profile + required: [id, display_name] + properties: + id: + type: string + description: Snapchat Public Profile ID + display_name: + type: string + description: Display name of the public profile + username: + type: string + description: Username/handle + profile_image_url: + type: string + description: Profile image URL + subscriber_count: + type: integer + description: Number of subscribers + tempToken: + type: string + description: Temporary Snapchat access token from OAuth + userProfile: + type: object + description: User profile data from OAuth redirect + refreshToken: + type: string + description: Snapchat refresh token (if available) + expiresIn: + type: integer + description: Token expiration time in seconds + redirect_url: + type: string + format: uri + description: Custom redirect URL after connection completes + example: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + selectedPublicProfile: + id: "abc123-def456" + display_name: "My Brand" + username: "mybrand" + profile_image_url: "https://cf-st.sc-cdn.net/..." + subscriber_count: 15000 + tempToken: "eyJ..." + userProfile: + id: "user123" + username: "mybrand" + displayName: "My Brand" + profilePicture: "https://cf-st.sc-cdn.net/..." + redirect_url: "https://yourapp.com/callback" + responses: + '200': + description: Snapchat Public Profile connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + redirect_url: { type: string, description: Redirect URL with connection params (if provided in request) } + account: + type: object + properties: + accountId: + type: string + description: ID of the created SocialAccount + platform: { type: string, enum: [snapchat] } + username: { type: string } + displayName: { type: string } + profilePicture: { type: string } + isActive: { type: boolean } + publicProfileName: { type: string } + example: + message: "Snapchat connected successfully with public profile" + redirect_url: "https://yourdomain.com/integrations/callback?connected=snapchat&profileId=507f1f77bcf86cd799439011&publicProfile=My+Brand" + account: + accountId: "64e1f0a9e2b5af0012ab34cd" + platform: "snapchat" + username: "mybrand" + displayName: "My Brand" + profilePicture: "https://cf-st.sc-cdn.net/..." + isActive: true + publicProfileName: "My Brand" + '400': + description: Missing required fields + content: + application/json: + example: + error: "Missing required fields" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: No access to profile or profile limit exceeded + content: + application/json: + examples: + forbidden: + value: { error: "Forbidden" } + limitExceeded: + value: + error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles." + code: "PROFILE_LIMIT_EXCEEDED" + '500': + description: Failed to connect Snapchat account + + /v1/connect/bluesky/credentials: + post: + operationId: connectBlueskyCredentials + tags: [Connect] + summary: Connect Bluesky account + description: | + Connect a Bluesky account using identifier (handle or email) and an app password. + To get your userId for the state parameter, call GET /v1/users which includes a currentUserId field. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [identifier, appPassword, state] + properties: + identifier: + type: string + description: Your Bluesky handle (e.g. user.bsky.social) or email address + appPassword: + type: string + description: App password generated from Bluesky Settings > App Passwords + state: + type: string + description: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles. + example: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1" + redirectUri: + type: string + format: uri + description: Optional URL to redirect to after successful connection + example: + identifier: "yourhandle.bsky.social" + appPassword: "xxxx-xxxx-xxxx-xxxx" + state: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1" + redirectUri: "https://yourapp.com/connected" + responses: + '200': + description: Bluesky connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + account: { $ref: '#/components/schemas/SocialAccount' } + example: + message: "Bluesky connected successfully" + account: + platform: "bluesky" + username: "yourhandle.bsky.social" + displayName: "Your Name" + isActive: true + redirectUrl: "https://zernio.com/dashboard/profiles/64f0.../accounts" + '400': { description: Invalid request - missing fields or invalid state format } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { description: Internal error } + + /v1/connect/whatsapp/credentials: + post: + operationId: connectWhatsAppCredentials + tags: [Connect] + summary: Connect WhatsApp via credentials + description: | + Connect a WhatsApp Business Account by providing Meta credentials directly. + This is the headless alternative to the Embedded Signup browser flow. + + To get the required credentials: + 1. Go to Meta Business Suite (business.facebook.com) + 2. Create or select a WhatsApp Business Account + 3. In Business Settings > System Users, create a System User + 4. Assign it the `whatsapp_business_management` and `whatsapp_business_messaging` permissions + 5. Generate a permanent access token + 6. Get the WABA ID from WhatsApp Manager > Account Tools > Phone Numbers + 7. Get the Phone Number ID from the same page (click on the number) + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, accessToken, wabaId, phoneNumberId] + properties: + profileId: + type: string + description: Your Late profile ID + accessToken: + type: string + description: Permanent System User access token from Meta Business Suite + wabaId: + type: string + description: WhatsApp Business Account ID from Meta + phoneNumberId: + type: string + description: Phone Number ID from Meta WhatsApp Manager + example: + profileId: "6507a1b2c3d4e5f6a7b8c9d0" + accessToken: "EAABsbCS...your-system-user-token" + wabaId: "123456789012345" + phoneNumberId: "987654321098765" + responses: + '200': + description: WhatsApp connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + account: + type: object + properties: + accountId: { type: string } + platform: { type: string, enum: [whatsapp] } + username: { type: string, description: Display phone number } + displayName: { type: string, description: Meta-verified business name } + isActive: { type: boolean } + phoneNumber: { type: string } + verifiedName: { type: string } + qualityRating: { type: string, description: "GREEN, YELLOW, or RED" } + example: + message: "WhatsApp connected successfully" + account: + accountId: "6507a1b2c3d4e5f6a7b8c9d0" + platform: "whatsapp" + username: "+1 555-123-4567" + displayName: "Acme Corp" + isActive: true + phoneNumber: "+1 555-123-4567" + verifiedName: "Acme Corp" + qualityRating: "GREEN" + '400': + description: | + Invalid request. Either missing fields or the phoneNumberId was not found + in the specified WABA. If the phone was not found, the response includes + `availablePhoneNumbers` to help identify the correct ID. + '401': + description: Invalid or expired access token + '403': + description: Profile limit exceeded for this plan + + /v1/connect/telegram: + get: + operationId: getTelegramConnectStatus + tags: [Connect] + summary: Generate Telegram code + description: Generate an access code (valid 15 minutes) for connecting a Telegram channel or group. Add the bot as admin, then send the code + @yourchannel to the bot. Poll PATCH /v1/connect/telegram to check status. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + description: The profile ID to connect the Telegram account to + responses: + '200': + description: Access code generated + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: The access code to send to the Telegram bot + example: "ZRN-ABC123" + expiresAt: + type: string + format: date-time + description: When the code expires + expiresIn: + type: integer + description: Seconds until expiration + example: 900 + botUsername: + type: string + description: The Telegram bot username to message + example: "LateScheduleBot" + instructions: + type: array + items: { type: string } + description: Step-by-step connection instructions + example: + code: "ZRN-ABC123" + expiresAt: "2024-01-15T12:30:00.000Z" + expiresIn: 900 + botUsername: "LateScheduleBot" + instructions: + - "1. Add @ZernioScheduleBot as an administrator in your channel/group" + - "2. Open a private chat with @ZernioScheduleBot" + - "3. Send: ZRN-ABC123 @yourchannel (replace @yourchannel with your channel username)" + - "4. Wait for confirmation - the connection will appear in your dashboard" + - "Tip: If your channel has no public username, forward a message from it along with the code" + '400': { description: Profile ID required or invalid format } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: No access to this profile } + '404': { description: Profile not found } + '500': { description: Internal error } + post: + operationId: initiateTelegramConnect + tags: [Connect] + summary: Connect Telegram directly + description: Connect a Telegram channel/group directly using the chat ID. Alternative to the access code flow. The bot must already be an admin in the channel/group. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [chatId, profileId] + properties: + chatId: + type: string + description: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel"). + profileId: + type: string + description: The profile ID to connect the account to + example: + chatId: "-1001234567890" + profileId: "6507a1b2c3d4e5f6a7b8c9d0" + responses: + '200': + description: Telegram channel connected successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + account: + type: object + properties: + _id: { type: string } + platform: { type: string, enum: [telegram] } + username: { type: string } + displayName: { type: string } + isActive: { type: boolean } + chatType: { type: string, enum: [channel, group, supergroup, private] } + example: + message: "Telegram channel connected successfully" + account: + _id: "64e1f0a9e2b5af0012ab34cd" + platform: "telegram" + username: "mychannel" + displayName: "My Channel" + isActive: true + chatType: "channel" + '400': { description: Chat ID required, bot not admin, or cannot access chat } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: No access to this profile } + '404': { description: Profile not found } + '500': { description: Internal error } + patch: + operationId: completeTelegramConnect + tags: [Connect] + summary: Check Telegram status + description: | + Poll this endpoint to check if a Telegram access code has been used to connect a channel/group. Recommended polling interval: 3 seconds. + Status values: pending (waiting for user), connected (channel/group linked), expired (generate a new code). + parameters: + - name: code + in: query + required: true + schema: { type: string } + description: The access code to check status for + example: "ZRN-ABC123" + responses: + '200': + description: Connection status + content: + application/json: + schema: + oneOf: + - type: object + title: Pending + properties: + status: { type: string, enum: [pending] } + expiresAt: { type: string, format: date-time } + expiresIn: { type: integer, description: Seconds until expiration } + - type: object + title: Connected + properties: + status: { type: string, enum: [connected] } + chatId: { type: string } + chatTitle: { type: string } + chatType: { type: string, enum: [channel, group, supergroup] } + account: + type: object + properties: + _id: { type: string } + platform: { type: string } + username: { type: string } + displayName: { type: string } + - type: object + title: Expired + properties: + status: { type: string, enum: [expired] } + message: { type: string } + examples: + pending: + summary: Waiting for connection + value: + status: "pending" + expiresAt: "2024-01-15T12:30:00.000Z" + expiresIn: 542 + connected: + summary: Successfully connected + value: + status: "connected" + chatId: "-1001234567890" + chatTitle: "My Channel" + chatType: "channel" + account: + _id: "64e1f0a9e2b5af0012ab34cd" + platform: "telegram" + username: "mychannel" + displayName: "My Channel" + expired: + summary: Code expired + value: + status: "expired" + message: "Access code has expired. Please generate a new one." + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Code not found } + '500': { description: Internal error } + + /v1/accounts/{accountId}/facebook-page: + get: + operationId: getFacebookPages + tags: [Connect] + summary: List Facebook pages + description: Returns all Facebook pages the connected account has access to, including the currently selected page. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Pages list + content: + application/json: + schema: + type: object + properties: + pages: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + username: { type: string } + category: { type: string } + fan_count: { type: integer } + selectedPageId: { type: string } + cached: { type: boolean } + example: + pages: + - id: "123456789012345" + name: "My Brand Page" + username: "mybrand" + category: "Brand" + fan_count: 5000 + - id: "234567890123456" + name: "My Other Page" + username: "myotherpage" + category: "Business" + fan_count: 1200 + selectedPageId: "123456789012345" + cached: true + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + put: + operationId: updateFacebookPage + tags: [Connect] + summary: Update Facebook page + description: Switch which Facebook Page is active for a connected account. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [selectedPageId] + properties: + selectedPageId: { type: string } + example: + selectedPageId: "123456789012345" + responses: + '200': + description: Page updated + content: + application/json: + schema: + type: object + properties: + message: { type: string } + selectedPage: + type: object + properties: + id: { type: string } + name: { type: string } + example: + message: "Facebook page updated successfully" + selectedPage: + id: "123456789012345" + name: "My Brand Page" + '400': { description: Page not in available pages } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/accounts/{accountId}/linkedin-organizations: + get: + operationId: getLinkedInOrganizations + tags: [Connect] + summary: List LinkedIn orgs + description: Returns LinkedIn organizations (company pages) the connected account has admin access to. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Organizations list + content: + application/json: + schema: + type: object + properties: + organizations: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + vanityName: { type: string } + localizedName: { type: string } + example: + organizations: + - id: "12345678" + name: "Acme Corporation" + vanityName: "acme-corp" + localizedName: "Acme Corporation" + - id: "87654321" + name: "Acme Subsidiary" + vanityName: "acme-sub" + localizedName: "Acme Subsidiary" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/accounts/{accountId}/linkedin-aggregate-analytics: + get: + operationId: getLinkedInAggregateAnalytics + tags: [Analytics] + summary: Get LinkedIn aggregate stats + description: Returns aggregate analytics across all posts for a LinkedIn personal account. Org accounts should use /v1/analytics instead. Requires r_member_postAnalytics scope. + parameters: + - name: accountId + in: path + required: true + description: The ID of the LinkedIn personal account + schema: { type: string } + - name: aggregation + in: query + required: false + description: TOTAL (default, lifetime totals) or DAILY (time series). MEMBERS_REACHED not available with DAILY. + schema: + type: string + enum: [TOTAL, DAILY] + default: TOTAL + - name: startDate + in: query + required: false + description: Start date (YYYY-MM-DD). If omitted, returns lifetime analytics. + schema: + type: string + format: date + example: "2024-01-01" + - name: endDate + in: query + required: false + description: End date (YYYY-MM-DD, exclusive). Defaults to today if omitted. + schema: + type: string + format: date + example: "2024-01-31" + - name: metrics + in: query + required: false + description: "Comma-separated metrics: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE. Omit for all." + schema: + type: string + example: "IMPRESSION,REACTION,COMMENT" + responses: + '200': + description: Aggregate analytics data + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/LinkedInAggregateAnalyticsTotalResponse' + - $ref: '#/components/schemas/LinkedInAggregateAnalyticsDailyResponse' + examples: + totalAggregation: + summary: TOTAL aggregation (lifetime totals) + value: + accountId: "64abc123def456" + platform: "linkedin" + accountType: "personal" + username: "John Doe" + aggregation: "TOTAL" + dateRange: null + analytics: + impressions: 1250000 + reach: 450000 + reactions: 7500 + comments: 2500 + shares: 1200 + engagementRate: 0.90 + note: "Aggregate analytics across all posts on this LinkedIn personal account (lifetime totals)." + lastUpdated: "2025-01-15T10:30:00.000Z" + totalWithDateRange: + summary: TOTAL aggregation with date range + value: + accountId: "64abc123def456" + platform: "linkedin" + accountType: "personal" + username: "John Doe" + aggregation: "TOTAL" + dateRange: + startDate: "2024-01-01" + endDate: "2024-01-31" + analytics: + impressions: 125000 + reach: 45000 + reactions: 750 + comments: 250 + shares: 120 + engagementRate: 0.90 + note: "Aggregate analytics for the specified date range." + lastUpdated: "2025-01-15T10:30:00.000Z" + dailyAggregation: + summary: DAILY aggregation (time series) + value: + accountId: "64abc123def456" + platform: "linkedin" + accountType: "personal" + username: "John Doe" + aggregation: "DAILY" + dateRange: + startDate: "2024-05-04" + endDate: "2024-05-06" + analytics: + impressions: + - date: "2024-05-04" + count: 1500 + - date: "2024-05-05" + count: 2300 + reactions: + - date: "2024-05-04" + count: 10 + - date: "2024-05-05" + count: 20 + comments: + - date: "2024-05-04" + count: 3 + - date: "2024-05-05" + count: 5 + shares: + - date: "2024-05-04" + count: 2 + - date: "2024-05-05" + count: 4 + skippedMetrics: + - "MEMBERS_REACHED (not supported with DAILY aggregation)" + note: "Daily breakdown of analytics across all posts. MEMBERS_REACHED is not available with DAILY aggregation per LinkedIn API limitations." + lastUpdated: "2025-01-15T10:30:00.000Z" + '400': + description: Invalid request + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string } + validOptions: { type: array, items: { type: string } } + examples: + not_linkedin: + summary: Not a LinkedIn account + value: + error: "This endpoint only supports LinkedIn accounts" + code: "invalid_platform" + organization: + summary: Org account not supported + value: + error: "Aggregate analytics only available for LinkedIn personal accounts. Organization accounts can use per-post analytics via /v1/analytics." + code: "organization_not_supported" + invalid_aggregation: + summary: Invalid aggregation type + value: + error: "Invalid aggregation type. Must be one of: TOTAL, DAILY" + code: "invalid_aggregation" + validOptions: ["TOTAL", "DAILY"] + invalid_date: + summary: Invalid date format + value: + error: "Invalid date format. Use YYYY-MM-DD format." + code: "invalid_date_format" + example: + startDate: "2024-01-01" + endDate: "2024-01-31" + invalid_metrics: + summary: Invalid metrics requested + value: + error: "Invalid metrics: INVALID_METRIC. Valid options: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE" + code: "invalid_metrics" + validOptions: ["IMPRESSION", "MEMBERS_REACHED", "REACTION", "COMMENT", "RESHARE"] + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string } + '403': + description: Missing required LinkedIn scope + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string, example: missing_scope } + requiredScope: { type: string, example: r_member_postAnalytics } + action: { type: string, example: reconnect } + example: + error: "Missing r_member_postAnalytics scope. Please reconnect your LinkedIn account to grant analytics permissions." + code: "missing_scope" + requiredScope: "r_member_postAnalytics" + action: "reconnect" + '404': { description: Account not found } + + /v1/accounts/{accountId}/linkedin-post-analytics: + get: + operationId: getLinkedInPostAnalytics + tags: [Analytics] + summary: Get LinkedIn post stats + description: Returns analytics for a specific LinkedIn post by URN. Works for both personal and organization accounts. + parameters: + - name: accountId + in: path + required: true + description: The ID of the LinkedIn account + schema: { type: string } + - name: urn + in: query + required: true + description: The LinkedIn post URN + schema: { type: string } + example: "urn:li:share:7123456789012345678" + responses: + '200': + description: Post analytics data + content: + application/json: + schema: + type: object + properties: + accountId: { type: string } + platform: { type: string, example: linkedin } + accountType: { type: string, enum: [personal, organization] } + username: { type: string } + postUrn: { type: string } + analytics: + type: object + properties: + impressions: { type: integer, description: Times the post was shown } + reach: { type: integer, description: Unique members who saw the post } + likes: { type: integer, description: Reactions on the post } + comments: { type: integer, description: Comments on the post } + shares: { type: integer, description: Reshares of the post } + clicks: { type: integer, description: Clicks on the post (organization accounts only) } + views: { type: integer, description: Video views (video posts only) } + engagementRate: { type: number, description: Engagement rate as percentage } + lastUpdated: { type: string, format: date-time } + example: + accountId: "64abc123def456" + platform: "linkedin" + accountType: "personal" + username: "John Doe" + postUrn: "urn:li:share:7123456789012345678" + analytics: + impressions: 5420 + reach: 3200 + likes: 156 + comments: 23 + shares: 12 + clicks: 0 + views: 1250 + engagementRate: 5.17 + lastUpdated: "2025-01-15T10:30:00.000Z" + '400': + description: Invalid request + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string, enum: [missing_urn, invalid_urn, invalid_platform] } + examples: + missing_urn: + value: + error: "Missing required parameter: urn" + code: "missing_urn" + example: "urn:li:share:7123456789012345678 or urn:li:ugcPost:7123456789012345678" + invalid_urn: + value: + error: "Invalid URN format. Must be urn:li:share:ID or urn:li:ugcPost:ID" + code: "invalid_urn" + providedUrn: "invalid-urn" + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + '403': + description: Missing required LinkedIn scope + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string, example: missing_scope } + requiredScope: { type: string } + action: { type: string, example: reconnect } + '404': + description: Account or post not found + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string } + examples: + account_not_found: + value: + error: "Account not found" + post_not_found: + value: + error: "Post not found. The URN may be invalid or the post may have been deleted." + code: "post_not_found" + postUrn: "urn:li:share:123" + + /v1/accounts/{accountId}/linkedin-post-reactions: + get: + operationId: getLinkedInPostReactions + tags: [Analytics] + summary: Get LinkedIn post reactions + description: | + Returns individual reactions for a specific LinkedIn post, including reactor profiles + (name, headline/job title, profile picture, profile URL, reaction type). + Only works for **organization/company page** accounts. LinkedIn restricts reaction + data for personal profiles (r_member_social_feed is a closed permission). + parameters: + - name: accountId + in: path + required: true + description: The ID of the LinkedIn organization account + schema: { type: string } + - name: urn + in: query + required: true + description: The LinkedIn post URN + schema: { type: string } + example: "urn:li:share:7123456789012345678" + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + description: Maximum number of reactions to return per page + - name: cursor + in: query + schema: { type: string } + description: Offset-based pagination start index + responses: + '200': + description: Reactions with reactor profiles + content: + application/json: + schema: + type: object + properties: + accountId: { type: string } + platform: { type: string, example: linkedin } + accountType: { type: string, example: organization } + username: { type: string } + postUrn: { type: string } + reactions: + type: array + items: + type: object + properties: + reactionType: + type: string + description: LinkedIn reaction enum (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT) + reactionLabel: + type: string + description: User-friendly label (Like, Celebrate, Love, Insightful, Support, Funny) + reactedAt: { type: string, format: date-time } + from: + type: object + properties: + urn: { type: string, description: "LinkedIn person or organization URN" } + name: { type: string, description: "Reactor's display name" } + headline: { type: string, description: "Reactor's headline/job title" } + username: { type: string, description: "LinkedIn vanity name" } + profilePicture: { type: string, description: "Profile picture URL" } + profileUrl: { type: string, description: "Direct link to LinkedIn profile" } + pagination: + type: object + properties: + hasMore: { type: boolean } + cursor: { type: string, description: "Offset for next page" } + total: { type: integer, description: "Total number of reactions (when available)" } + lastUpdated: { type: string, format: date-time } + example: + accountId: "64abc123def456" + platform: "linkedin" + accountType: "organization" + username: "Acme Corp" + postUrn: "urn:li:share:7123456789012345678" + reactions: + - reactionType: "LIKE" + reactionLabel: "Like" + reactedAt: "2026-03-08T12:00:00.000Z" + from: + urn: "urn:li:person:abc123" + name: "Jane Smith" + headline: "Product Manager at Acme Corp" + username: "janesmith" + profilePicture: "https://media.licdn.com/..." + profileUrl: "https://www.linkedin.com/in/janesmith" + pagination: + hasMore: true + cursor: "25" + total: 156 + lastUpdated: "2026-03-08T12:00:00.000Z" + '400': + description: Invalid request or platform limitation + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: { type: string, enum: [missing_urn, invalid_urn, invalid_platform, PLATFORM_LIMITATION] } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Analytics add-on required + '403': + description: Missing required LinkedIn scope + '404': + description: Account or post not found + + /v1/accounts/{accountId}/linkedin-organization: + put: + operationId: updateLinkedInOrganization + tags: [Connect] + summary: Switch LinkedIn account type + description: Switch a LinkedIn account between personal profile and organization (company page) posting. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountType] + properties: + accountType: { type: string, enum: [personal, organization] } + selectedOrganization: { type: object } + example: + accountType: "organization" + selectedOrganization: + id: "12345678" + name: "Acme Corporation" + vanityName: "acme-corp" + responses: + '200': + description: Account updated + content: + application/json: + schema: + type: object + properties: + message: { type: string } + account: { $ref: '#/components/schemas/SocialAccount' } + example: + message: "LinkedIn account type updated successfully" + account: + _id: "64e1f0a9e2b5af0012ab34cd" + platform: "linkedin" + username: "acme-corp" + displayName: "Acme Corporation" + isActive: true + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/accounts/{accountId}/linkedin-mentions: + get: + operationId: getLinkedInMentions + tags: [LinkedIn Mentions] + summary: Resolve LinkedIn mention + description: Converts a LinkedIn profile or company URL to a URN for @mentions in posts. Person mentions require org admin access. Use the returned mentionFormat in post content. + parameters: + - name: accountId + in: path + required: true + description: The LinkedIn account ID + schema: { type: string } + - name: url + in: query + required: true + description: LinkedIn profile URL, company URL, or vanity name. + schema: { type: string } + examples: + personVanityName: + value: "miquelpalet" + summary: Person - just the vanity name + personFullUrl: + value: "https://www.linkedin.com/in/miquelpalet" + summary: Person - full LinkedIn URL + orgShortUrl: + value: "company/microsoft" + summary: Org - short format + orgFullUrl: + value: "https://www.linkedin.com/company/microsoft" + summary: Org - full LinkedIn URL + - name: displayName + in: query + required: false + description: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions. + schema: { type: string } + examples: + personName: + value: "Miquel Palet" + summary: Exact name as shown on LinkedIn profile + orgName: + value: "Microsoft" + summary: Company name (optional for orgs) + responses: + '200': + description: URN resolved successfully + content: + application/json: + schema: + type: object + properties: + urn: + type: string + description: The LinkedIn URN (person or organization) + example: "urn:li:person:4qj5ox-agD" + type: + type: string + enum: [person, organization] + description: The type of entity (person or organization) + example: "person" + displayName: + type: string + description: Display name (provided, from API, or derived from vanity URL) + example: "Miquel Palet" + mentionFormat: + type: string + description: Ready-to-use mention format for post content + example: "@[Miquel Palet](urn:li:person:4qj5ox-agD)" + vanityName: + type: string + description: The vanity name/slug (only for organization mentions) + example: "microsoft" + warning: + type: string + description: Warning about clickable mentions (only present for person mentions if displayName was not provided) + example: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile." + examples: + personWithDisplayName: + summary: Person mention with displayName (recommended) + value: + urn: "urn:li:person:4qj5ox-agD" + type: "person" + displayName: "Miquel Palet" + mentionFormat: "@[Miquel Palet](urn:li:person:4qj5ox-agD)" + personWithoutDisplayName: + summary: Person mention without displayName (may not be clickable) + value: + urn: "urn:li:person:4qj5ox-agD" + type: "person" + displayName: "Miquelpalet" + mentionFormat: "@[Miquelpalet](urn:li:person:4qj5ox-agD)" + warning: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile." + organization: + summary: Org mention + value: + urn: "urn:li:organization:1035" + type: "organization" + displayName: "Microsoft" + mentionFormat: "@[Microsoft](urn:li:organization:1035)" + vanityName: "microsoft" + '400': + description: Invalid request or no organization found (for person mentions) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + examples: + missingUrl: + value: { error: "url parameter is required" } + noOrgForPersonMention: + value: { error: "No organization found. You need to be an admin of a LinkedIn organization to use person mentions. Organization mentions work without this requirement." } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Person or organization not found + content: + application/json: + schema: + type: object + properties: + error: { type: string } + examples: + memberNotFound: + value: { error: "Member not found. Check the LinkedIn URL is correct." } + orgNotFound: + value: { error: "Organization not found. Check the LinkedIn company URL is correct." } + + /v1/accounts/{accountId}/pinterest-boards: + get: + operationId: getPinterestBoards + tags: [Connect] + summary: List Pinterest boards + description: Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Boards list + content: + application/json: + schema: + type: object + properties: + boards: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + description: { type: string } + privacy: { type: string } + example: + boards: + - id: "123456789012345678" + name: "Marketing Ideas" + description: "Collection of marketing inspiration" + privacy: "PUBLIC" + - id: "234567890123456789" + name: "Product Photos" + description: "Product photography" + privacy: "PUBLIC" + '400': { description: Not a Pinterest account } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + put: + operationId: updatePinterestBoards + tags: [Connect] + summary: Set default Pinterest board + description: Sets the default board used when publishing pins for this account. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [defaultBoardId] + properties: + defaultBoardId: { type: string } + defaultBoardName: { type: string } + example: + defaultBoardId: "123456789012345678" + defaultBoardName: "Marketing Ideas" + responses: + '200': + description: Default board set + content: + application/json: + schema: + type: object + properties: + message: { type: string } + account: { $ref: '#/components/schemas/SocialAccount' } + example: + message: "Default Pinterest board updated successfully" + account: + _id: "64e1f0a9e2b5af0012ab34cd" + platform: "pinterest" + username: "mybrand" + displayName: "My Brand" + isActive: true + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/accounts/{accountId}/gmb-locations: + get: + operationId: getGmbLocations + tags: [Connect] + summary: List GBP locations + description: Returns all Google Business Profile locations the connected account has access to, including the currently selected location. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Locations list + content: + application/json: + schema: + type: object + properties: + locations: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + accountId: { type: string } + accountName: { type: string } + address: { type: string } + category: { type: string } + websiteUrl: { type: string } + selectedLocationId: { type: string } + cached: { type: boolean } + example: + locations: + - id: "12345678901234567890" + name: "My Business Location" + accountId: "accounts/123456789" + accountName: "My Business Account" + address: "123 Main St, San Francisco, CA" + category: "Restaurant" + websiteUrl: "https://mybusiness.com" + selectedLocationId: "12345678901234567890" + cached: true + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + put: + operationId: updateGmbLocation + tags: [Connect] + summary: Update GBP location + description: Switch which GBP location is active for a connected account. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [selectedLocationId] + properties: + selectedLocationId: { type: string } + example: + selectedLocationId: "12345678901234567890" + responses: + '200': + description: Location updated + content: + application/json: + schema: + type: object + properties: + message: { type: string } + selectedLocation: + type: object + properties: + id: { type: string } + name: { type: string } + example: + message: "Google Business location updated successfully" + selectedLocation: + id: "12345678901234567890" + name: "My Business Location" + '400': { description: Location not in available locations } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/accounts/{accountId}/reddit-subreddits: + get: + operationId: getRedditSubreddits + tags: [Connect] + summary: List Reddit subreddits + description: Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Subreddits list + content: + application/json: + schema: + type: object + properties: + subreddits: + type: array + items: + type: object + properties: + id: { type: string, description: Reddit subreddit ID } + name: { type: string, description: Subreddit name without r/ prefix } + title: { type: string, description: Subreddit title } + url: { type: string, description: Subreddit URL path } + over18: { type: boolean, description: Whether the subreddit is NSFW } + defaultSubreddit: + type: string + description: Currently set default subreddit for posting + example: + subreddits: + - id: "2qh1i" + name: "marketing" + title: "Marketing" + url: "/r/marketing/" + over18: false + - id: "2qh3l" + name: "socialmedia" + title: "Social Media" + url: "/r/socialmedia/" + over18: false + defaultSubreddit: "marketing" + '400': { description: Not a Reddit account } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + put: + operationId: updateRedditSubreddits + tags: [Connect] + summary: Set default subreddit + description: Sets the default subreddit used when publishing posts for this Reddit account. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [defaultSubreddit] + properties: + defaultSubreddit: { type: string } + example: + defaultSubreddit: "marketing" + responses: + '200': + description: Default subreddit set + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + example: + success: true + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/accounts/{accountId}/reddit-flairs: + get: + operationId: getRedditFlairs + tags: [Connect] + summary: List subreddit flairs + description: Returns available post flairs for a subreddit. Some subreddits require a flair when posting. + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + - name: subreddit + in: query + required: true + schema: { type: string } + description: Subreddit name (without "r/" prefix) to fetch flairs for + responses: + '200': + description: Flairs list + content: + application/json: + schema: + type: object + properties: + flairs: + type: array + items: + type: object + properties: + id: { type: string, description: Flair ID to pass as flairId in platformSpecificData } + text: { type: string, description: Flair display text } + textColor: { type: string, description: "Text color: 'dark' or 'light'" } + backgroundColor: { type: string, description: "Background hex color (e.g. '#ff4500')" } + example: + flairs: + - id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + text: "Discussion" + textColor: "dark" + backgroundColor: "#edeff1" + - id: "b2c3d4e5-f6a7-8901-bcde-f12345678901" + text: "News" + textColor: "light" + backgroundColor: "#ff4500" + '400': { description: Not a Reddit account or missing subreddit parameter } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/queue/slots: + get: + operationId: listQueueSlots + tags: [Queue] + summary: List schedules + description: Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + description: Profile ID to get queues for + - name: queueId + in: query + required: false + schema: { type: string } + description: Specific queue ID to retrieve (optional) + - name: all + in: query + required: false + schema: { type: string, enum: ['true'] } + description: Set to 'true' to list all queues for the profile + responses: + '200': + description: Queue schedule(s) retrieved + content: + application/json: + schema: + oneOf: + - type: object + description: Single queue response (default behavior) + properties: + exists: { type: boolean } + schedule: + $ref: '#/components/schemas/QueueSchedule' + nextSlots: + type: array + items: { type: string, format: date-time } + - type: object + description: All queues response (when all=true) + properties: + queues: + type: array + items: + $ref: '#/components/schemas/QueueSchedule' + count: { type: integer } + examples: + singleQueue: + summary: Single queue response + value: + exists: true + schedule: + _id: "64f0a1b2c3d4e5f6a7b8c9d1" + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + name: "Morning Posts" + timezone: "America/New_York" + slots: + - dayOfWeek: 1 + time: "09:00" + - dayOfWeek: 3 + time: "09:00" + - dayOfWeek: 5 + time: "10:00" + active: true + isDefault: true + nextSlots: + - "2024-11-04T09:00:00-05:00" + - "2024-11-06T09:00:00-05:00" + allQueues: + summary: All queues response (all=true) + value: + queues: + - _id: "64f0a1b2c3d4e5f6a7b8c9d1" + name: "Morning Posts" + isDefault: true + timezone: "America/New_York" + slots: [{ dayOfWeek: 1, time: "09:00" }] + active: true + - _id: "64f0a1b2c3d4e5f6a7b8c9d2" + name: "Evening Content" + isDefault: false + timezone: "America/New_York" + slots: [{ dayOfWeek: 1, time: "18:00" }] + active: true + count: 2 + '400': { description: Missing profileId } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Profile not found } + post: + operationId: createQueueSlot + tags: [Queue] + summary: Create schedule + description: | + Create an additional queue for a profile. The first queue created becomes the default. + Subsequent queues are non-default unless explicitly set. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, name, timezone, slots] + properties: + profileId: { type: string, description: Profile ID } + name: { type: string, description: "Queue name (e.g., Evening Posts)" } + timezone: { type: string, description: IANA timezone } + slots: + type: array + items: + $ref: '#/components/schemas/QueueSlot' + active: { type: boolean, default: true } + example: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + name: "Evening Posts" + timezone: "America/New_York" + slots: + - dayOfWeek: 1 + time: "18:00" + - dayOfWeek: 3 + time: "18:00" + - dayOfWeek: 5 + time: "18:00" + active: true + responses: + '201': + description: Queue created + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + schedule: + $ref: '#/components/schemas/QueueSchedule' + nextSlots: + type: array + items: { type: string, format: date-time } + '400': { description: Invalid request or validation error } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Profile not found } + put: + operationId: updateQueueSlot + tags: [Queue] + summary: Update schedule + description: | + Create a new queue or update an existing one. Without queueId, creates/updates the default queue. With queueId, updates a specific queue. With setAsDefault=true, makes this queue the default for the profile. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [profileId, timezone, slots] + properties: + profileId: { type: string } + queueId: { type: string, description: Queue ID to update (optional) } + name: { type: string, description: Queue name } + timezone: { type: string } + slots: + type: array + items: + $ref: '#/components/schemas/QueueSlot' + active: { type: boolean, default: true } + setAsDefault: { type: boolean, description: Make this queue the default } + reshuffleExisting: + type: boolean + default: false + description: Whether to reschedule existing queued posts to match new slots + example: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + queueId: "64f0a1b2c3d4e5f6a7b8c9d1" + name: "Morning Posts" + timezone: "America/New_York" + slots: + - dayOfWeek: 1 + time: "09:00" + - dayOfWeek: 3 + time: "09:00" + - dayOfWeek: 5 + time: "10:00" + active: true + setAsDefault: false + responses: + '200': + description: Queue schedule updated + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + schedule: + $ref: '#/components/schemas/QueueSchedule' + nextSlots: + type: array + items: { type: string, format: date-time } + reshuffledCount: { type: integer } + example: + success: true + schedule: + _id: "64f0a1b2c3d4e5f6a7b8c9d1" + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + name: "Morning Posts" + timezone: "America/New_York" + slots: + - dayOfWeek: 1 + time: "09:00" + - dayOfWeek: 3 + time: "09:00" + - dayOfWeek: 5 + time: "10:00" + active: true + isDefault: true + nextSlots: + - "2024-11-04T09:00:00-05:00" + - "2024-11-06T09:00:00-05:00" + reshuffledCount: 0 + '400': { description: Invalid request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Profile not found } + delete: + operationId: deleteQueueSlot + tags: [Queue] + summary: Delete schedule + description: | + Delete a queue from a profile. Requires queueId to specify which queue to delete. + If deleting the default queue, another queue will be promoted to default. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + - name: queueId + in: query + required: true + schema: { type: string } + description: Queue ID to delete + responses: + '200': + description: Queue schedule deleted + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + deleted: { type: boolean } + example: + success: true + deleted: true + '400': { description: Missing profileId or queueId } + '401': { $ref: '#/components/responses/Unauthorized' } + /v1/queue/preview: + get: + operationId: previewQueue + tags: [Queue] + summary: Preview upcoming slots + description: Returns the next N upcoming queue slot times for a profile as ISO datetime strings. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + - name: queueId + in: query + schema: { type: string } + description: Filter by specific queue ID. Omit to use the default queue. + - name: count + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + '200': + description: Queue slots preview + content: + application/json: + schema: + type: object + properties: + profileId: { type: string } + count: { type: integer } + slots: + type: array + items: { type: string, format: date-time } + example: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + count: 10 + slots: + - "2024-11-04T09:00:00-05:00" + - "2024-11-04T14:00:00-05:00" + - "2024-11-06T09:00:00-05:00" + - "2024-11-08T10:00:00-05:00" + - "2024-11-11T09:00:00-05:00" + - "2024-11-11T14:00:00-05:00" + - "2024-11-13T09:00:00-05:00" + - "2024-11-15T10:00:00-05:00" + - "2024-11-18T09:00:00-05:00" + - "2024-11-18T14:00:00-05:00" + '400': { description: Invalid parameters } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Profile or queue schedule not found } + /v1/queue/next-slot: + get: + operationId: getNextQueueSlot + tags: [Queue] + summary: Get next available slot + description: Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor. + parameters: + - name: profileId + in: query + required: true + schema: { type: string } + - name: queueId + in: query + required: false + schema: { type: string } + description: Specific queue ID (optional, defaults to profile's default queue) + responses: + '200': + description: Next available slot + content: + application/json: + schema: + type: object + properties: + profileId: { type: string } + nextSlot: { type: string, format: date-time } + timezone: { type: string } + queueId: { type: string, description: Queue ID this slot belongs to } + queueName: { type: string, description: Queue name } + example: + profileId: "64f0a1b2c3d4e5f6a7b8c9d0" + nextSlot: "2024-11-04T09:00:00-05:00" + timezone: "America/New_York" + queueId: "64f0a1b2c3d4e5f6a7b8c9d1" + queueName: "Morning Posts" + '400': + description: Invalid parameters or inactive queue + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: "Profile or queue schedule not found, or no available slots" + # ============================================ + # Webhooks API (Multi-Webhook System) + # ============================================ + /v1/webhooks/settings: + get: + operationId: getWebhookSettings + tags: [Webhooks] + summary: List webhooks + description: Retrieve all configured webhooks for the authenticated user. Supports up to 10 webhooks per user. + security: + - bearerAuth: [] + responses: + '200': + description: Webhooks retrieved successfully + content: + application/json: + schema: + type: object + properties: + webhooks: + type: array + items: + $ref: '#/components/schemas/Webhook' + example: + webhooks: + - _id: "507f1f77bcf86cd799439011" + name: "My Production Webhook" + url: "https://example.com/webhook" + events: ["post.published", "post.failed"] + isActive: true + lastFiredAt: "2024-01-15T10:30:00Z" + failureCount: 0 + - _id: "507f1f77bcf86cd799439012" + name: "Slack Notifications" + url: "https://hooks.slack.com/services/xxx" + events: ["post.failed", "account.disconnected"] + isActive: true + failureCount: 0 + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: createWebhookSettings + tags: [Webhooks] + summary: Create webhook + description: | + Create a new webhook configuration. Maximum 10 webhooks per user. + + Webhooks are automatically disabled after 10 consecutive delivery failures. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Webhook name (max 50 characters) + maxLength: 50 + url: + type: string + format: uri + description: Webhook endpoint URL (must be HTTPS in production) + secret: + type: string + description: Secret key for HMAC-SHA256 signature verification + events: + type: array + items: + type: string + enum: [post.scheduled, post.published, post.failed, post.partial, post.recycled, account.connected, account.disconnected, message.received, comment.received] + description: Events to subscribe to + isActive: + type: boolean + description: Enable or disable webhook delivery + customHeaders: + type: object + additionalProperties: + type: string + description: Custom headers to include in webhook requests + examples: + createWebhook: + summary: Create webhook with all events + value: + name: "My Production Webhook" + url: "https://example.com/webhook" + secret: "your-secret-key" + events: ["post.scheduled", "post.published", "post.failed", "post.partial", "account.connected", "account.disconnected", "message.received", "comment.received"] + isActive: true + responses: + '200': + description: Webhook created successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + webhook: + $ref: '#/components/schemas/Webhook' + '400': { description: Validation error or maximum webhooks reached } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: updateWebhookSettings + tags: [Webhooks] + summary: Update webhook + description: | + Update an existing webhook configuration. All fields except _id are optional; only provided fields will be updated. + + Webhooks are automatically disabled after 10 consecutive delivery failures. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - _id + properties: + _id: + type: string + description: Webhook ID to update (required) + name: + type: string + description: Webhook name (max 50 characters) + maxLength: 50 + url: + type: string + format: uri + description: Webhook endpoint URL (must be HTTPS in production) + secret: + type: string + description: Secret key for HMAC-SHA256 signature verification + events: + type: array + items: + type: string + enum: [post.scheduled, post.published, post.failed, post.partial, post.recycled, account.connected, account.disconnected, message.received, comment.received] + description: Events to subscribe to + isActive: + type: boolean + description: Enable or disable webhook delivery + customHeaders: + type: object + additionalProperties: + type: string + description: Custom headers to include in webhook requests + examples: + updateWebhook: + summary: Update webhook URL and events + value: + _id: "507f1f77bcf86cd799439011" + url: "https://new-example.com/webhook" + events: ["post.published", "post.failed"] + toggleWebhook: + summary: Enable/disable webhook + value: + _id: "507f1f77bcf86cd799439011" + isActive: false + responses: + '200': + description: Webhook updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + webhook: + $ref: '#/components/schemas/Webhook' + '400': { description: Validation error or missing webhook ID } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Webhook not found } + delete: + operationId: deleteWebhookSettings + tags: [Webhooks] + summary: Delete webhook + description: Permanently delete a webhook configuration. + security: + - bearerAuth: [] + parameters: + - name: id + in: query + required: true + description: Webhook ID to delete + schema: + type: string + responses: + '200': + description: Webhook deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + '400': { description: Webhook ID required } + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/webhooks/test: + post: + operationId: testWebhook + tags: [Webhooks] + summary: Send test webhook + description: | + Send a test webhook to verify your endpoint is configured correctly. The test payload includes event: "webhook.test" to distinguish it from real events. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - webhookId + properties: + webhookId: + type: string + description: ID of the webhook to test + example: + webhookId: "507f1f77bcf86cd799439011" + responses: + '200': + description: Test webhook sent successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: true + message: "Test webhook sent successfully" + '400': { description: Webhook ID required } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': + description: Test webhook failed to deliver + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: false + message: "Test webhook failed" + + /v1/webhooks/logs: + get: + operationId: getWebhookLogs + tags: [Webhooks] + summary: Get delivery logs + description: | + Retrieve webhook delivery history. Logs are automatically deleted after 7 days. + security: + - bearerAuth: [] + parameters: + - name: limit + in: query + description: Maximum number of logs to return (max 100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: status + in: query + description: Filter by delivery status + schema: + type: string + enum: [success, failed] + - name: event + in: query + description: Filter by event type + schema: + type: string + enum: [post.scheduled, post.published, post.failed, post.partial, post.recycled, account.connected, account.disconnected, message.received, comment.received, webhook.test] + - name: webhookId + in: query + description: Filter by webhook ID + schema: + type: string + responses: + '200': + description: Webhook logs retrieved successfully + content: + application/json: + schema: + type: object + properties: + logs: + type: array + items: + $ref: '#/components/schemas/WebhookLog' + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/posts/logs: + get: + operationId: listPostsLogs + tags: [Logs] + summary: List publishing logs + description: | + Retrieve publishing logs for all posts with detailed information about each publishing attempt. Filter by status, platform, or action. Logs are automatically deleted after 7 days. + security: + - bearerAuth: [] + parameters: + - name: status + in: query + description: Filter by log status + schema: + type: string + enum: [success, failed, pending, skipped, all] + - name: platform + in: query + description: Filter by platform + schema: + type: string + enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, all] + - name: action + in: query + description: Filter by action type + schema: + type: string + enum: [publish, retry, media_upload, rate_limit_pause, token_refresh, cancelled, all] + - name: days + in: query + description: Number of days to look back (max 7) + schema: + type: integer + minimum: 1 + maximum: 7 + default: 7 + - name: limit + in: query + description: Maximum number of logs to return (max 100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: skip + in: query + description: Number of logs to skip (for pagination) + schema: + type: integer + minimum: 0 + default: 0 + - name: search + in: query + description: Search through log entries by text content. + schema: + type: string + responses: + '200': + description: Publishing logs retrieved successfully + content: + application/json: + schema: + type: object + properties: + logs: + type: array + items: + $ref: '#/components/schemas/PostLog' + pagination: + type: object + properties: + total: + type: integer + description: Total number of logs matching the query + limit: + type: integer + skip: + type: integer + pages: + type: integer + description: Total number of pages + hasMore: + type: boolean + example: + logs: + - _id: "675f1c0a9e2b5af0012ab34cd" + postId: + _id: "65f1c0a9e2b5af0012ab34cd" + content: "Check out our new feature!" + status: "published" + userId: "64e1f0a9e2b5af0012ab34de" + platform: "instagram" + accountId: "64e1f0a9e2b5af0012ab34ef" + accountUsername: "@acmecorp" + action: "publish" + status: "success" + statusCode: 200 + endpoint: "graph.facebook.com/me/media_publish" + request: + contentPreview: "Check out our new feature!" + mediaCount: 1 + mediaTypes: ["image"] + mediaUrls: ["https://storage.zernio.com/abc123.jpg"] + response: + platformPostId: "17895695668004550" + platformPostUrl: "https://www.instagram.com/p/ABC123/" + durationMs: 2340 + attemptNumber: 1 + createdAt: "2024-11-01T10:00:05Z" + pagination: + total: 150 + limit: 50 + skip: 0 + pages: 3 + hasMore: true + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/connections/logs: + get: + operationId: listConnectionLogs + tags: [Logs] + summary: List connection logs + description: | + Retrieve connection event logs showing account connection and disconnection history. Event types: connect_success, connect_failed, disconnect, reconnect_success, reconnect_failed. + Logs are automatically deleted after 7 days. + security: + - bearerAuth: [] + parameters: + - name: platform + in: query + description: Filter by platform + schema: + type: string + enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, all] + - name: eventType + in: query + description: Filter by event type + schema: + type: string + enum: [connect_success, connect_failed, disconnect, reconnect_success, reconnect_failed, all] + - name: status + in: query + description: Filter by status (shorthand for event types) + schema: + type: string + enum: [success, failed, all] + description: success = connect_success + reconnect_success, failed = connect_failed + reconnect_failed + - name: days + in: query + description: Number of days to look back (max 7) + schema: + type: integer + minimum: 1 + maximum: 7 + default: 7 + - name: limit + in: query + description: Maximum number of logs to return (max 100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: skip + in: query + description: Number of logs to skip (for pagination) + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Connection logs retrieved successfully + content: + application/json: + schema: + type: object + properties: + logs: + type: array + items: + $ref: '#/components/schemas/ConnectionLog' + pagination: + type: object + properties: + total: + type: integer + description: Total number of logs matching the query + limit: + type: integer + skip: + type: integer + pages: + type: integer + description: Total number of pages + hasMore: + type: boolean + example: + logs: + - _id: "675f1c0a9e2b5af0012ab34cd" + userId: "64e1f0a9e2b5af0012ab34de" + profileId: "64e1f0a9e2b5af0012ab34ef" + accountId: "64e1f0a9e2b5af0012ab3500" + platform: "instagram" + eventType: "connect_success" + connectionMethod: "oauth" + success: + displayName: "Acme Corp" + username: "acmecorp" + profilePicture: "https://..." + permissions: ["instagram_basic", "instagram_content_publish"] + tokenExpiresAt: "2024-12-01T10:00:00Z" + accountType: "business" + context: + hasCustomRedirectUrl: false + createdAt: "2024-11-01T10:00:00Z" + - _id: "675f1c0a9e2b5af0012ab34ce" + userId: "64e1f0a9e2b5af0012ab34de" + profileId: "64e1f0a9e2b5af0012ab34ef" + platform: "twitter" + eventType: "connect_failed" + connectionMethod: "oauth" + error: + code: "oauth_denied" + message: "OAuth error: access_denied" + context: + hasCustomRedirectUrl: true + createdAt: "2024-11-01T09:00:00Z" + pagination: + total: 25 + limit: 50 + skip: 0 + pages: 1 + hasMore: false + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/posts/{postId}/logs: + get: + operationId: getPostLogs + tags: [Logs] + summary: Get post logs + description: | + Retrieve all publishing logs for a specific post. Shows the complete history + of publishing attempts for that post across all platforms. + security: + - bearerAuth: [] + parameters: + - name: postId + in: path + required: true + description: The post ID + schema: + type: string + - name: limit + in: query + description: Maximum number of logs to return (max 100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Post logs retrieved successfully + content: + application/json: + schema: + type: object + properties: + logs: + type: array + items: + $ref: '#/components/schemas/PostLog' + count: + type: integer + description: Number of logs returned + postId: + type: string + example: + logs: + - _id: "675f1c0a9e2b5af0012ab34cd" + postId: "65f1c0a9e2b5af0012ab34cd" + userId: "64e1f0a9e2b5af0012ab34de" + platform: "instagram" + accountUsername: "@acmecorp" + action: "publish" + status: "success" + statusCode: 200 + durationMs: 2340 + createdAt: "2024-11-01T10:00:05Z" + - _id: "675f1c0a9e2b5af0012ab34ce" + postId: "65f1c0a9e2b5af0012ab34cd" + userId: "64e1f0a9e2b5af0012ab34de" + platform: "twitter" + accountUsername: "@acme" + action: "publish" + status: "failed" + statusCode: 429 + response: + errorMessage: "Rate limit exceeded" + errorCode: "RATE_LIMITED" + durationMs: 150 + attemptNumber: 1 + createdAt: "2024-11-01T10:00:03Z" + count: 2 + postId: "65f1c0a9e2b5af0012ab34cd" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden - not authorized to view this post + '404': { $ref: '#/components/responses/NotFound' } + + # Unified Inbox Endpoints + /v1/inbox/conversations: + get: + operationId: listInboxConversations + summary: List conversations + description: | + Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated. + Supported platforms: Facebook, Instagram, Twitter/X, Bluesky, Reddit, Telegram. + tags: [Messages] + security: [{ bearerAuth: [] }] + parameters: + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID + - name: platform + in: query + schema: { type: string, enum: [facebook, instagram, twitter, bluesky, reddit, telegram] } + description: Filter by platform + - name: status + in: query + schema: { type: string, enum: [active, archived] } + description: Filter by conversation status + - name: sortOrder + in: query + schema: { type: string, enum: [asc, desc], default: desc } + description: Sort order by updated time + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + description: Maximum number of conversations to return + - name: cursor + in: query + schema: { type: string } + description: Pagination cursor for next page + - name: accountId + in: query + schema: { type: string } + description: Filter by specific social account ID + responses: + '200': + description: Aggregated conversations + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: { type: string } + platform: { type: string } + accountId: { type: string } + accountUsername: { type: string } + participantId: { type: string } + participantName: { type: string } + participantPicture: { type: string, nullable: true } + lastMessage: { type: string } + updatedTime: { type: string, format: date-time } + status: { type: string, enum: [active, archived] } + unreadCount: { type: integer, nullable: true, description: Number of unread messages } + url: + type: string + nullable: true + description: Direct link to open the conversation on the platform (if available) + instagramProfile: + type: object + nullable: true + description: Instagram profile data for the participant. Only present for Instagram conversations. + properties: + isFollower: + type: boolean + nullable: true + description: Whether the participant follows your Instagram business account + isFollowing: + type: boolean + nullable: true + description: Whether your Instagram business account follows the participant + followerCount: + type: integer + nullable: true + description: The participant's follower count on Instagram + isVerified: + type: boolean + nullable: true + description: Whether the participant is a verified Instagram user + fetchedAt: + type: string + format: date-time + nullable: true + description: When this profile data was last fetched from Instagram + pagination: + type: object + properties: + hasMore: { type: boolean } + nextCursor: { type: string, nullable: true } + meta: + type: object + properties: + accountsQueried: { type: integer } + accountsFailed: { type: integer } + failedAccounts: + type: array + items: + type: object + properties: + accountId: { type: string } + accountUsername: { type: string, nullable: true } + platform: { type: string } + error: { type: string } + code: { type: string, nullable: true, description: Error code if available } + retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) } + lastUpdated: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + /v1/inbox/conversations/{conversationId}: + get: + operationId: getInboxConversation + summary: Get conversation + description: Retrieve details and metadata for a specific conversation. Requires accountId query parameter. + tags: [Messages] + security: [{ bearerAuth: [] }] + parameters: + - name: conversationId + in: path + required: true + schema: { type: string } + description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. + - name: accountId + in: query + required: true + schema: { type: string } + description: The social account ID + responses: + '200': + description: Conversation details + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + id: { type: string } + accountId: { type: string } + accountUsername: { type: string } + platform: { type: string } + status: { type: string, enum: [active, archived] } + participantName: { type: string } + participantId: { type: string } + lastMessage: { type: string } + lastMessageAt: { type: string, format: date-time } + updatedTime: { type: string, format: date-time } + participants: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + instagramProfile: + type: object + nullable: true + description: Instagram profile data for the participant. Only present for Instagram conversations. + properties: + isFollower: + type: boolean + nullable: true + description: Whether the participant follows your Instagram business account + isFollowing: + type: boolean + nullable: true + description: Whether your Instagram business account follows the participant + followerCount: + type: integer + nullable: true + description: The participant's follower count on Instagram + isVerified: + type: boolean + nullable: true + description: Whether the participant is a verified Instagram user + fetchedAt: + type: string + format: date-time + nullable: true + description: When this profile data was last fetched from Instagram + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + '404': + description: Conversation not found + put: + operationId: updateInboxConversation + summary: Update conversation status + description: Archive or activate a conversation. Requires accountId in request body. + tags: [Messages] + security: [{ bearerAuth: [] }] + parameters: + - name: conversationId + in: path + required: true + schema: { type: string } + description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, status] + properties: + accountId: { type: string, description: Social account ID } + status: { type: string, enum: [active, archived] } + responses: + '200': + description: Conversation updated + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + data: + type: object + properties: + id: { type: string } + accountId: { type: string } + status: { type: string, enum: [active, archived] } + platform: { type: string } + updatedAt: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/conversations/{conversationId}/messages: + get: + operationId: getInboxConversationMessages + summary: List messages + description: Fetch messages for a specific conversation. Requires accountId query parameter. + tags: [Messages] + security: [{ bearerAuth: [] }] + parameters: + - name: conversationId + in: path + required: true + schema: { type: string } + description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. + - name: accountId + in: query + required: true + schema: { type: string } + description: Social account ID + responses: + '200': + description: Messages in conversation + content: + application/json: + schema: + type: object + properties: + status: { type: string } + messages: + type: array + items: + type: object + properties: + id: { type: string } + conversationId: { type: string } + accountId: { type: string } + platform: { type: string } + message: { type: string } + senderId: { type: string } + senderName: { type: string, nullable: true } + direction: { type: string, enum: [incoming, outgoing] } + createdAt: { type: string, format: date-time } + attachments: + type: array + items: + type: object + properties: + id: { type: string } + type: { type: string, enum: [image, video, audio, file, sticker, share] } + url: { type: string } + filename: { type: string, nullable: true } + previewUrl: { type: string, nullable: true } + subject: { type: string, nullable: true, description: Reddit message subject } + storyReply: { type: boolean, nullable: true, description: Instagram story reply } + isStoryMention: { type: boolean, nullable: true, description: Instagram story mention } + lastUpdated: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + post: + operationId: sendInboxMessage + summary: Send message + description: Send a message in a conversation. Supports text, attachments, quick replies, buttons, and message tags. Attachment and interactive message support varies by platform. + tags: [Messages] + security: [{ bearerAuth: [] }] + parameters: + - name: conversationId + in: path + required: true + schema: { type: string } + description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId] + properties: + accountId: { type: string, description: Social account ID } + message: { type: string, description: Message text } + quickReplies: + type: array + maxItems: 13 + description: Quick reply buttons. Mutually exclusive with buttons. Max 13 items. + items: + type: object + required: [title, payload] + properties: + title: { type: string, maxLength: 20, description: Button label (max 20 chars) } + payload: { type: string, description: Payload sent back on tap } + imageUrl: { type: string, description: Optional icon URL (Meta only) } + buttons: + type: array + maxItems: 3 + description: Action buttons. Mutually exclusive with quickReplies. Max 3 items. + items: + type: object + required: [type, title] + properties: + type: { type: string, enum: [url, postback, phone], description: Button type. phone is Facebook only. } + title: { type: string, maxLength: 20, description: Button label (max 20 chars) } + url: { type: string, description: URL for url-type buttons } + payload: { type: string, description: Payload for postback-type buttons } + phone: { type: string, description: Phone number for phone-type buttons (Facebook only) } + template: + type: object + description: Generic template for carousels (Instagram/Facebook only, ignored on Telegram). + properties: + type: { type: string, enum: [generic], description: Template type } + elements: + type: array + maxItems: 10 + items: + type: object + required: [title] + properties: + title: { type: string, maxLength: 80, description: Element title (max 80 chars) } + subtitle: { type: string, description: Element subtitle } + imageUrl: { type: string, description: Element image URL } + buttons: + type: array + maxItems: 3 + items: + type: object + properties: + type: { type: string, enum: [url, postback] } + title: { type: string, maxLength: 20 } + url: { type: string } + payload: { type: string } + replyMarkup: + type: object + description: Telegram-native keyboard markup. Ignored on other platforms. + properties: + type: { type: string, enum: [inline_keyboard, reply_keyboard], description: Keyboard type } + keyboard: + type: array + description: Array of rows, each row is an array of buttons + items: + type: array + items: + type: object + properties: + text: { type: string, description: Button text } + callbackData: { type: string, maxLength: 64, description: Callback data (inline_keyboard only, max 64 bytes) } + url: { type: string, description: URL to open (inline_keyboard only) } + oneTime: { type: boolean, default: true, description: Hide keyboard after use (reply_keyboard only) } + messagingType: + type: string + enum: [RESPONSE, UPDATE, MESSAGE_TAG] + description: Facebook messaging type. Required when using messageTag. + messageTag: + type: string + enum: [CONFIRMED_EVENT_UPDATE, POST_PURCHASE_UPDATE, ACCOUNT_UPDATE, HUMAN_AGENT] + description: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT. + replyTo: + type: string + description: Platform message ID to reply to (Telegram only). + multipart/form-data: + schema: + type: object + required: [accountId] + properties: + accountId: { type: string, description: Social account ID } + message: { type: string, description: Message text (optional when sending attachment) } + attachment: + type: string + format: binary + description: "File attachment (images, videos, documents). Supported formats: JPEG, PNG, GIF, MP4, AAC, WAV. Max 25MB." + quickReplies: + type: string + description: JSON string of quick replies array (same schema as application/json body) + buttons: + type: string + description: JSON string of buttons array (same schema as application/json body) + template: + type: string + description: JSON string of template object (same schema as application/json body) + replyMarkup: + type: string + description: JSON string of replyMarkup object (same schema as application/json body) + messagingType: + type: string + description: Messaging type (Facebook only). RESPONSE, UPDATE, or MESSAGE_TAG. + messageTag: + type: string + description: Message tag (requires messagingType MESSAGE_TAG) + replyTo: + type: string + description: Platform message ID to reply to (Telegram only) + responses: + '200': + description: Message sent + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + data: + type: object + properties: + messageId: { type: string, description: ID of the sent message (not returned for Reddit) } + conversationId: { type: string, nullable: true, description: Twitter conversation ID } + sentAt: { type: string, format: date-time, nullable: true, description: Bluesky sent timestamp } + message: { type: string, nullable: true, description: Success message (Reddit only) } + '400': + description: Bad request (e.g., attachment not supported for platform, validation error) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + code: + type: string + enum: [PLATFORM_LIMITATION] + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/conversations/{conversationId}/messages/{messageId}: + patch: + operationId: editInboxMessage + summary: Edit message + description: | + Edit the text and/or reply markup of a previously sent Telegram message. + Only supported for Telegram. Returns 400 for other platforms. + tags: [Messages] + security: [{ bearerAuth: [] }] + parameters: + - name: conversationId + in: path + required: true + schema: { type: string } + description: The conversation ID + - name: messageId + in: path + required: true + schema: { type: string } + description: The Telegram message ID to edit + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId] + properties: + accountId: { type: string, description: Social account ID } + text: { type: string, description: New message text } + replyMarkup: + type: object + description: New inline keyboard markup + properties: + type: { type: string, enum: [inline_keyboard] } + keyboard: + type: array + items: + type: array + items: + type: object + properties: + text: { type: string } + callbackData: { type: string } + url: { type: string } + responses: + '200': + description: Message edited + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + data: + type: object + properties: + messageId: { type: integer } + '400': + description: Not supported or invalid request + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/accounts/{accountId}/messenger-menu: + get: + operationId: getMessengerMenu + summary: Get FB persistent menu + description: Get the persistent menu configuration for a Facebook Messenger account. + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Persistent menu configuration + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { type: object } } + '400': + description: Not a Facebook account + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: setMessengerMenu + summary: Set FB persistent menu + description: Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items. + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [persistent_menu] + properties: + persistent_menu: + type: array + description: Persistent menu configuration array (Meta format) + items: { type: object } + responses: + '200': + description: Menu set successfully + '400': + description: Invalid request + '401': { $ref: '#/components/responses/Unauthorized' } + delete: + operationId: deleteMessengerMenu + description: Removes the persistent menu from Facebook Messenger conversations for this account. + summary: Delete FB persistent menu + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Menu deleted + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/accounts/{accountId}/instagram-ice-breakers: + get: + operationId: getInstagramIceBreakers + summary: Get IG ice breakers + description: Get the ice breaker configuration for an Instagram account. + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Ice breaker configuration + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { type: object } } + '400': + description: Not an Instagram account + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: setInstagramIceBreakers + summary: Set IG ice breakers + description: Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars. + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ice_breakers] + properties: + ice_breakers: + type: array + maxItems: 4 + items: + type: object + required: [question, payload] + properties: + question: { type: string, maxLength: 80 } + payload: { type: string } + responses: + '200': + description: Ice breakers set successfully + '400': + description: Invalid request + '401': { $ref: '#/components/responses/Unauthorized' } + delete: + operationId: deleteInstagramIceBreakers + description: Removes the ice breaker questions from an Instagram account's Messenger experience. + summary: Delete IG ice breakers + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Ice breakers deleted + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/accounts/{accountId}/telegram-commands: + get: + operationId: getTelegramCommands + summary: Get TG bot commands + description: Get the bot commands configuration for a Telegram account. + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Bot commands list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + command: { type: string } + description: { type: string } + '400': + description: Not a Telegram account + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: setTelegramCommands + summary: Set TG bot commands + description: Set bot commands for a Telegram account. + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [commands] + properties: + commands: + type: array + items: + type: object + required: [command, description] + properties: + command: { type: string, description: Bot command without leading slash } + description: { type: string, description: Command description } + responses: + '200': + description: Commands set successfully + '400': + description: Invalid request + '401': { $ref: '#/components/responses/Unauthorized' } + delete: + operationId: deleteTelegramCommands + description: Clears all bot commands configured for a Telegram bot account. + summary: Delete TG bot commands + tags: [Account Settings] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: path + required: true + schema: { type: string } + responses: + '200': + description: Commands deleted + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/inbox/comments: + get: + operationId: listInboxComments + summary: List commented posts + description: Returns posts with comment counts from all connected accounts. Aggregates data across multiple accounts. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: profileId + in: query + schema: { type: string } + description: Filter by profile ID + - name: platform + in: query + schema: { type: string, enum: [facebook, instagram, twitter, bluesky, threads, youtube, linkedin, reddit] } + description: Filter by platform + - name: minComments + in: query + schema: { type: integer, minimum: 0 } + description: Minimum comment count + - name: since + in: query + schema: { type: string, format: date-time } + description: Posts created after this date + - name: sortBy + in: query + schema: { type: string, enum: [date, comments], default: date } + description: Sort field + - name: sortOrder + in: query + schema: { type: string, enum: [asc, desc], default: desc } + description: Sort order + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + - name: cursor + in: query + schema: { type: string } + - name: accountId + in: query + schema: { type: string } + description: Filter by specific social account ID + responses: + '200': + description: Aggregated posts with comments + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: { type: string } + platform: { type: string } + accountId: { type: string } + accountUsername: { type: string } + content: { type: string } + picture: { type: string, nullable: true } + permalink: { type: string, nullable: true } + createdTime: { type: string, format: date-time } + commentCount: { type: integer } + likeCount: { type: integer } + cid: { type: string, nullable: true, description: Bluesky content identifier } + subreddit: { type: string, nullable: true, description: Reddit subreddit name } + pagination: + type: object + properties: + hasMore: { type: boolean } + nextCursor: { type: string, nullable: true } + meta: + type: object + properties: + accountsQueried: { type: integer } + accountsFailed: { type: integer } + failedAccounts: + type: array + items: + type: object + properties: + accountId: { type: string } + accountUsername: { type: string, nullable: true } + platform: { type: string } + error: { type: string } + code: { type: string, nullable: true, description: Error code if available } + retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) } + lastUpdated: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/comments/{postId}: + get: + operationId: getInboxPostComments + summary: Get post comments + description: Fetch comments for a specific post. Requires accountId query parameter. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + description: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID. + schema: { type: string } + - name: accountId + in: query + required: true + schema: { type: string } + - name: subreddit + in: query + schema: { type: string } + description: (Reddit only) Subreddit name + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + description: Maximum number of comments to return + - name: cursor + in: query + schema: { type: string } + description: Pagination cursor + - name: commentId + in: query + schema: { type: string } + description: (Reddit only) Get replies to a specific comment + responses: + '200': + description: Comments for the post + content: + application/json: + schema: + type: object + properties: + status: { type: string } + comments: + type: array + items: + type: object + properties: + id: { type: string } + message: { type: string } + createdTime: { type: string, format: date-time } + from: + type: object + properties: + id: { type: string } + name: { type: string } + username: { type: string } + picture: { type: string, nullable: true } + isOwner: { type: boolean } + likeCount: { type: integer } + replyCount: { type: integer } + platform: { type: string, description: The platform this comment is from } + url: + type: string + nullable: true + description: Direct link to the comment on the platform (if available) + replies: + type: array + items: { type: object } + canReply: { type: boolean } + canDelete: { type: boolean } + canHide: { type: boolean, description: Whether this comment can be hidden (Facebook, Instagram, Threads) } + canLike: { type: boolean, description: Whether this comment can be liked (Facebook, Twitter/X, Bluesky, Reddit) } + isHidden: { type: boolean, description: Whether the comment is currently hidden } + isLiked: { type: boolean, description: Whether the current user has liked this comment } + likeUri: { type: string, nullable: true, description: Bluesky like URI for unliking } + cid: { type: string, nullable: true, description: Bluesky content identifier } + parentId: { type: string, nullable: true, description: Parent comment ID for nested replies } + rootUri: { type: string, nullable: true, description: Bluesky root post URI } + rootCid: { type: string, nullable: true, description: Bluesky root post CID } + pagination: + type: object + properties: + hasMore: { type: boolean } + cursor: { type: string, nullable: true } + meta: + type: object + properties: + platform: { type: string } + postId: { type: string } + accountId: { type: string } + subreddit: { type: string, nullable: true, description: (Reddit only) Subreddit name } + lastUpdated: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + post: + operationId: replyToInboxPost + summary: Reply to comment + description: Post a reply to a post or specific comment. Requires accountId in request body. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, message] + properties: + accountId: { type: string } + message: { type: string } + commentId: { type: string, description: Reply to specific comment (optional) } + parentCid: { type: string, description: (Bluesky only) Parent content identifier } + rootUri: { type: string, description: (Bluesky only) Root post URI } + rootCid: { type: string, description: (Bluesky only) Root post CID } + responses: + '200': + description: Reply posted + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + data: + type: object + properties: + commentId: { type: string } + isReply: { type: boolean } + cid: { type: string, nullable: true, description: Bluesky CID } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + delete: + operationId: deleteInboxComment + summary: Delete comment + description: | + Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn. + Requires accountId and commentId query parameters. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. + schema: { type: string } + - name: accountId + in: query + required: true + schema: { type: string } + - name: commentId + in: query + required: true + schema: { type: string } + responses: + '200': + description: Comment deleted + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + data: + type: object + properties: + message: { type: string } + '400': + description: Platform rejected the operation (e.g., comment already deleted, insufficient permissions on the video) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/comments/{postId}/{commentId}/hide: + post: + operationId: hideInboxComment + summary: Hide comment + description: | + Hide a comment on a post. Supported by Facebook, Instagram, Threads, and X/Twitter. + Hidden comments are only visible to the commenter and page admin. + For X/Twitter, the reply must belong to a conversation started by the authenticated user. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + schema: { type: string } + - name: commentId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId] + properties: + accountId: { type: string, description: The social account ID } + responses: + '200': + description: Comment hidden + content: + application/json: + schema: + type: object + properties: + status: { type: string } + commentId: { type: string } + hidden: { type: boolean } + platform: { type: string } + '400': + description: Platform does not support hiding comments + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + delete: + operationId: unhideInboxComment + summary: Unhide comment + description: | + Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + schema: { type: string } + - name: commentId + in: path + required: true + schema: { type: string } + - name: accountId + in: query + required: true + schema: { type: string } + responses: + '200': + description: Comment unhidden + content: + application/json: + schema: + type: object + properties: + status: { type: string } + commentId: { type: string } + hidden: { type: boolean } + platform: { type: string } + '400': + description: Platform does not support unhiding comments + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/comments/{postId}/{commentId}/like: + post: + operationId: likeInboxComment + summary: Like comment + description: | + Like or upvote a comment on a post. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit. + For Bluesky, the cid (content identifier) is required in the request body. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + schema: { type: string } + - name: commentId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId] + properties: + accountId: { type: string, description: The social account ID } + cid: { type: string, description: (Bluesky only) Content identifier for the comment } + responses: + '200': + description: Comment liked + content: + application/json: + schema: + type: object + properties: + status: { type: string } + commentId: { type: string } + liked: { type: boolean } + likeUri: { type: string, description: (Bluesky only) URI to use for unliking } + platform: { type: string } + '400': + description: Platform does not support liking comments + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + delete: + operationId: unlikeInboxComment + summary: Unlike comment + description: | + Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit. + For Bluesky, the likeUri query parameter is required. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + schema: { type: string } + - name: commentId + in: path + required: true + schema: { type: string } + - name: accountId + in: query + required: true + schema: { type: string } + - name: likeUri + in: query + schema: { type: string } + description: (Bluesky only) The like URI returned when liking + responses: + '200': + description: Comment unliked + content: + application/json: + schema: + type: object + properties: + status: { type: string } + commentId: { type: string } + liked: { type: boolean } + platform: { type: string } + '400': + description: Platform does not support unliking comments + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/comments/{postId}/{commentId}/private-reply: + post: + operationId: sendPrivateReplyToComment + summary: Send private reply + description: Send a private message to the author of a comment. Supported on Instagram and Facebook only. One reply per comment, must be sent within 7 days, text only. + tags: [Comments] + security: [{ bearerAuth: [] }] + parameters: + - name: postId + in: path + required: true + schema: { type: string } + description: The media/post ID (Instagram media ID or Facebook post ID) + - name: commentId + in: path + required: true + schema: { type: string } + description: The comment ID to send a private reply to + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, message] + properties: + accountId: + type: string + description: The social account ID (Instagram or Facebook) + message: + type: string + description: The message text to send as a private DM + example: + accountId: "507f1f77bcf86cd799439011" + message: "Hi! Thanks for your comment. I wanted to reach out privately to help with your question." + responses: + '200': + description: Private reply sent successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + messageId: + type: string + description: The ID of the sent message + commentId: + type: string + description: The comment ID that was replied to + platform: + type: string + enum: [instagram, facebook] + example: instagram + '400': + description: Bad request + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + enum: [PLATFORM_LIMITATION] + examples: + platformNotSupported: + summary: Platform not supported + value: + error: "Private replies to comments are only supported on Instagram and Facebook." + code: "PLATFORM_LIMITATION" + alreadyReplied: + summary: Already sent a private reply + value: + error: "A private reply has already been sent to this comment, or the 7-day reply window has expired. Only one private reply per comment is allowed within 7 days." + commentTooOld: + summary: Comment older than 7 days + value: + error: "The comment is older than 7 days. Private replies can only be sent within 7 days of the comment being posted." + missingMessage: + summary: Missing message + value: + error: "message is required and must be a non-empty string" + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + '404': + description: Account not found + + /v1/twitter/retweet: + post: + operationId: retweetPost + summary: Retweet a post + description: | + Retweet (repost) a tweet by ID. + Rate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation. + tags: [Twitter Engagement] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, tweetId] + properties: + accountId: { type: string, description: The social account ID } + tweetId: { type: string, description: The ID of the tweet to retweet } + responses: + '200': + description: Tweet retweeted + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: success } + tweetId: { type: string } + retweeted: { type: boolean } + platform: { type: string, example: twitter } + '400': { description: Bad request or platform limitation } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + delete: + operationId: undoRetweet + summary: Undo retweet + description: | + Undo a retweet (un-repost a tweet). + tags: [Twitter Engagement] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: query + required: true + schema: { type: string } + - name: tweetId + in: query + required: true + schema: { type: string } + description: The ID of the original tweet to un-retweet + responses: + '200': + description: Retweet undone + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: success } + tweetId: { type: string } + retweeted: { type: boolean, example: false } + platform: { type: string, example: twitter } + '400': { description: Bad request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/twitter/bookmark: + post: + operationId: bookmarkPost + summary: Bookmark a tweet + description: | + Bookmark a tweet by ID. + Requires the bookmark.write OAuth scope. + Rate limit: 50 requests per 15-min window. + tags: [Twitter Engagement] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, tweetId] + properties: + accountId: { type: string, description: The social account ID } + tweetId: { type: string, description: The ID of the tweet to bookmark } + responses: + '200': + description: Tweet bookmarked + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: success } + tweetId: { type: string } + bookmarked: { type: boolean } + platform: { type: string, example: twitter } + '400': { description: Bad request or platform limitation } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + delete: + operationId: removeBookmark + summary: Remove bookmark + description: | + Remove a bookmark from a tweet. + tags: [Twitter Engagement] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: query + required: true + schema: { type: string } + - name: tweetId + in: query + required: true + schema: { type: string } + description: The ID of the tweet to unbookmark + responses: + '200': + description: Bookmark removed + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: success } + tweetId: { type: string } + bookmarked: { type: boolean, example: false } + platform: { type: string, example: twitter } + '400': { description: Bad request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/twitter/follow: + post: + operationId: followUser + summary: Follow a user + description: | + Follow a user on X/Twitter. + Requires the follows.write OAuth scope. + For protected accounts, a follow request is sent instead (pending_follow will be true). + tags: [Twitter Engagement] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, targetUserId] + properties: + accountId: { type: string, description: The social account ID } + targetUserId: { type: string, description: The Twitter ID of the user to follow } + responses: + '200': + description: User followed or follow request sent + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: success } + targetUserId: { type: string } + following: { type: boolean } + pending_follow: { type: boolean, description: True if the target account is protected and a follow request was sent } + platform: { type: string, example: twitter } + '400': { description: Bad request or platform limitation } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + delete: + operationId: unfollowUser + summary: Unfollow a user + description: | + Unfollow a user on X/Twitter. + tags: [Twitter Engagement] + security: [{ bearerAuth: [] }] + parameters: + - name: accountId + in: query + required: true + schema: { type: string } + - name: targetUserId + in: query + required: true + schema: { type: string } + description: The Twitter ID of the user to unfollow + responses: + '200': + description: User unfollowed + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: success } + targetUserId: { type: string } + following: { type: boolean, example: false } + platform: { type: string, example: twitter } + '400': { description: Bad request } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: Account not found } + + /v1/inbox/reviews: + get: + operationId: listInboxReviews + summary: List reviews + description: | + Fetch reviews from all connected Facebook Pages and Google Business accounts. Aggregates data with filtering and sorting options. + Supported platforms: Facebook, Google Business. + tags: [Reviews] + security: [{ bearerAuth: [] }] + parameters: + - name: profileId + in: query + schema: { type: string } + - name: platform + in: query + schema: { type: string, enum: [facebook, googlebusiness] } + - name: minRating + in: query + schema: { type: integer, minimum: 1, maximum: 5 } + - name: maxRating + in: query + schema: { type: integer, minimum: 1, maximum: 5 } + - name: hasReply + in: query + schema: { type: boolean } + description: Filter by reply status + - name: sortBy + in: query + schema: { type: string, enum: [date, rating], default: date } + - name: sortOrder + in: query + schema: { type: string, enum: [asc, desc], default: desc } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 50, default: 25 } + - name: cursor + in: query + schema: { type: string } + - name: accountId + in: query + schema: { type: string } + description: Filter by specific social account ID + responses: + '200': + description: Aggregated reviews + content: + application/json: + schema: + type: object + properties: + status: { type: string } + data: + type: array + items: + type: object + properties: + id: { type: string } + platform: { type: string } + accountId: { type: string } + accountUsername: { type: string } + reviewer: + type: object + properties: + id: { type: string, nullable: true } + name: { type: string } + profileImage: { type: string, nullable: true } + rating: { type: integer } + text: { type: string } + created: { type: string, format: date-time } + hasReply: { type: boolean } + reply: + type: object + nullable: true + properties: + id: { type: string } + text: { type: string } + created: { type: string, format: date-time } + reviewUrl: { type: string, nullable: true } + pagination: + type: object + properties: + hasMore: { type: boolean } + nextCursor: { type: string, nullable: true } + meta: + type: object + properties: + accountsQueried: { type: integer } + accountsFailed: { type: integer } + failedAccounts: + type: array + items: + type: object + properties: + accountId: { type: string } + accountUsername: { type: string, nullable: true } + platform: { type: string } + error: { type: string } + code: { type: string, nullable: true, description: Error code if available } + retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) } + lastUpdated: { type: string, format: date-time } + summary: + type: object + properties: + totalReviews: { type: integer } + averageRating: { type: number, nullable: true } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + + /v1/inbox/reviews/{reviewId}/reply: + post: + operationId: replyToInboxReview + summary: Reply to review + description: Post a reply to a review. Requires accountId in request body. + tags: [Reviews] + security: [{ bearerAuth: [] }] + parameters: + - name: reviewId + in: path + required: true + schema: { type: string } + description: Review ID (URL-encoded for Google Business) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, message] + properties: + accountId: { type: string } + message: { type: string } + responses: + '200': + description: Reply posted + content: + application/json: + schema: + type: object + properties: + status: { type: string } + reply: + type: object + properties: + id: { type: string } + text: { type: string } + created: { type: string, format: date-time } + platform: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + delete: + operationId: deleteInboxReviewReply + summary: Delete review reply + description: Delete a reply to a review (Google Business only). Requires accountId in request body. + tags: [Reviews] + security: [{ bearerAuth: [] }] + parameters: + - name: reviewId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId] + properties: + accountId: { type: string } + responses: + '200': + description: Reply deleted + content: + application/json: + schema: + type: object + properties: + status: { type: string } + message: { type: string } + platform: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Inbox addon required + # ────────────────────────────────────────────────────────────────────────── + # BULK SEND + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/bulk: + post: + operationId: sendWhatsAppBulk + tags: [WhatsApp] + summary: Bulk send template messages + description: | + Send a template message to multiple recipients in a single request. Maximum 100 recipients per request. + Only template messages are supported for bulk sending (not free-form text). + + Each recipient can have optional per-recipient template variables for personalization. + Returns detailed results for each recipient. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - recipients + - template + properties: + accountId: + type: string + description: WhatsApp social account ID + recipients: + type: array + maxItems: 100 + description: List of recipients (max 100) + items: + type: object + required: + - phone + properties: + phone: + type: string + description: Recipient phone number in E.164 format + variables: + type: object + additionalProperties: { type: string } + description: Per-recipient template variables keyed by index (e.g., "1", "2") + template: + type: object + required: + - name + - language + properties: + name: + type: string + description: Template name + language: + type: string + description: Template language code + components: + type: array + description: Base template components + items: + type: object + example: + accountId: "507f1f77bcf86cd799439011" + recipients: + - phone: "+1234567890" + variables: { "1": "John" } + - phone: "+0987654321" + variables: { "1": "Jane" } + template: + name: "welcome_message" + language: "en_US" + responses: + '200': + description: Bulk send completed + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + summary: + type: object + properties: + total: { type: integer } + sent: { type: integer } + failed: { type: integer } + results: + type: array + items: + type: object + properties: + phone: { type: string } + success: { type: boolean } + messageId: { type: string } + error: { type: string } + '400': { description: Validation error (missing fields, too many recipients, etc.) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + # ────────────────────────────────────────────────────────────────────────── + # CONTACTS + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/contacts: + get: + operationId: getWhatsAppContacts + tags: [WhatsApp] + summary: List contacts + description: | + List WhatsApp contacts for an account. Supports filtering by tags, groups, opt-in status, + and text search. Returns contacts sorted by name with available filter options. + security: + - bearerAuth: [] + parameters: + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + - name: search + in: query + required: false + description: Search contacts by name, phone, email, or company + schema: + type: string + - name: tag + in: query + required: false + description: Filter by tag + schema: + type: string + - name: group + in: query + required: false + description: Filter by group + schema: + type: string + - name: optedIn + in: query + required: false + description: Filter by opt-in status + schema: + type: string + enum: ["true", "false"] + - name: limit + in: query + required: false + description: Maximum results (default 50) + schema: + type: integer + default: 50 + - name: skip + in: query + required: false + description: Offset for pagination + schema: + type: integer + default: 0 + responses: + '200': + description: Contacts retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + contacts: + type: array + items: + type: object + properties: + id: { type: string } + phone: { type: string } + waId: { type: string } + name: { type: string } + email: { type: string } + company: { type: string } + tags: + type: array + items: { type: string } + groups: + type: array + items: { type: string } + isOptedIn: { type: boolean } + lastMessageSentAt: { type: string, format: date-time } + lastMessageReceivedAt: { type: string, format: date-time } + messagesSentCount: { type: integer } + messagesReceivedCount: { type: integer } + customFields: { type: object } + notes: { type: string } + createdAt: { type: string, format: date-time } + filters: + type: object + properties: + tags: + type: array + items: { type: string } + groups: + type: array + items: { type: string } + pagination: + type: object + properties: + total: { type: integer } + limit: { type: integer } + skip: { type: integer } + hasMore: { type: boolean } + '400': { description: accountId is required } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + post: + operationId: createWhatsAppContact + tags: [WhatsApp] + summary: Create contact + description: | + Create a new WhatsApp contact. Phone number must be unique per account + and in E.164 format (e.g., +1234567890). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - phone + - name + properties: + accountId: + type: string + description: WhatsApp social account ID + phone: + type: string + description: Phone number in E.164 format + name: + type: string + description: Contact name + email: + type: string + description: Contact email + company: + type: string + description: Company name + tags: + type: array + items: { type: string } + description: Tags for categorization + groups: + type: array + items: { type: string } + description: Groups the contact belongs to + isOptedIn: + type: boolean + default: true + description: Whether the contact has opted in to receive messages + customFields: + type: object + additionalProperties: { type: string } + description: Custom key-value fields + notes: + type: string + description: Notes about the contact + example: + accountId: "507f1f77bcf86cd799439011" + phone: "+1234567890" + name: "John Doe" + email: "john@example.com" + tags: ["vip", "newsletter"] + groups: ["customers"] + responses: + '200': + description: Contact created successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + contact: + type: object + properties: + id: { type: string } + phone: { type: string } + name: { type: string } + email: { type: string } + company: { type: string } + tags: + type: array + items: { type: string } + groups: + type: array + items: { type: string } + isOptedIn: { type: boolean } + createdAt: { type: string, format: date-time } + '400': { description: Validation error (missing fields, invalid phone number) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + '409': { description: Contact with this phone number already exists } + + /v1/whatsapp/contacts/{contactId}: + get: + operationId: getWhatsAppContact + tags: [WhatsApp] + summary: Get contact + description: Retrieve a single WhatsApp contact by ID with full details. + security: + - bearerAuth: [] + parameters: + - name: contactId + in: path + required: true + description: Contact ID + schema: + type: string + responses: + '200': + description: Contact retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + contact: + type: object + properties: + id: { type: string } + phone: { type: string } + waId: { type: string } + name: { type: string } + email: { type: string } + company: { type: string } + tags: + type: array + items: { type: string } + groups: + type: array + items: { type: string } + isOptedIn: { type: boolean } + optInDate: { type: string, format: date-time } + optOutDate: { type: string, format: date-time } + isBlocked: { type: boolean } + lastMessageSentAt: { type: string, format: date-time } + lastMessageReceivedAt: { type: string, format: date-time } + messagesSentCount: { type: integer } + messagesReceivedCount: { type: integer } + customFields: { type: object } + notes: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + operationId: updateWhatsAppContact + tags: [WhatsApp] + summary: Update contact + description: | + Update an existing WhatsApp contact. All fields are optional; only provided fields will be updated. + Custom fields are merged with existing values. Set a custom field to null to remove it. + security: + - bearerAuth: [] + parameters: + - name: contactId + in: path + required: true + description: Contact ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Contact name + email: + type: string + description: Contact email + company: + type: string + description: Company name + tags: + type: array + items: { type: string } + description: Tags (replaces existing) + groups: + type: array + items: { type: string } + description: Groups (replaces existing) + isOptedIn: + type: boolean + description: Opt-in status (changes are timestamped) + isBlocked: + type: boolean + description: Block status + customFields: + type: object + additionalProperties: { type: string, nullable: true } + description: Custom fields to merge (set value to null to remove a field) + notes: + type: string + description: Notes about the contact + example: + name: "John Doe Updated" + tags: ["vip", "premium"] + customFields: { "plan": "enterprise", "remove_me": null } + responses: + '200': + description: Contact updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + contact: + type: object + properties: + id: { type: string } + phone: { type: string } + name: { type: string } + email: { type: string } + company: { type: string } + tags: + type: array + items: { type: string } + groups: + type: array + items: { type: string } + isOptedIn: { type: boolean } + isBlocked: { type: boolean } + customFields: { type: object } + notes: { type: string } + updatedAt: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteWhatsAppContact + tags: [WhatsApp] + summary: Delete contact + description: Permanently delete a WhatsApp contact. + security: + - bearerAuth: [] + parameters: + - name: contactId + in: path + required: true + description: Contact ID + schema: + type: string + responses: + '200': + description: Contact deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: true + message: "Contact deleted successfully" + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /v1/whatsapp/contacts/import: + post: + operationId: importWhatsAppContacts + tags: [WhatsApp] + summary: Bulk import contacts + description: | + Import up to 1000 contacts at once. Each contact requires a phone number and name. + Duplicates are skipped by default. Supports default tags and groups applied to all imported contacts. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - contacts + properties: + accountId: + type: string + description: WhatsApp social account ID + contacts: + type: array + maxItems: 1000 + description: Contacts to import (max 1000) + items: + type: object + required: + - phone + - name + properties: + phone: + type: string + description: Phone number in E.164 format + name: + type: string + description: Contact name + email: + type: string + company: + type: string + tags: + type: array + items: { type: string } + groups: + type: array + items: { type: string } + customFields: + type: object + additionalProperties: { type: string } + notes: + type: string + defaultTags: + type: array + items: { type: string } + description: Tags applied to all imported contacts + defaultGroups: + type: array + items: { type: string } + description: Groups applied to all imported contacts + skipDuplicates: + type: boolean + default: true + description: Skip contacts with existing phone numbers + example: + accountId: "507f1f77bcf86cd799439011" + contacts: + - phone: "+1234567890" + name: "John Doe" + email: "john@example.com" + - phone: "+0987654321" + name: "Jane Smith" + defaultTags: ["imported"] + defaultGroups: ["new-leads"] + responses: + '200': + description: Import completed + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + summary: + type: object + properties: + total: { type: integer } + created: { type: integer } + skipped: { type: integer } + failed: { type: integer } + results: + type: array + items: + type: object + properties: + phone: { type: string } + name: { type: string } + success: { type: boolean } + contactId: { type: string } + error: { type: string } + '400': { description: Validation error (missing fields, too many contacts) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + /v1/whatsapp/contacts/bulk: + post: + operationId: bulkUpdateWhatsAppContacts + tags: [WhatsApp] + summary: Bulk update contacts + description: | + Perform bulk operations on multiple contacts (max 500 per request). Supported actions: + addTags, removeTags, addGroups, removeGroups, optIn, optOut, block, unblock. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - action + - contactIds + properties: + action: + type: string + enum: [addTags, removeTags, addGroups, removeGroups, optIn, optOut, block, unblock] + description: Bulk action to perform + contactIds: + type: array + maxItems: 500 + items: { type: string } + description: Contact IDs to update (max 500) + tags: + type: array + items: { type: string } + description: Tags to add or remove (required for addTags/removeTags) + groups: + type: array + items: { type: string } + description: Groups to add or remove (required for addGroups/removeGroups) + examples: + addTags: + summary: Add tags to contacts + value: + action: "addTags" + contactIds: ["507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012"] + tags: ["vip", "priority"] + optOut: + summary: Opt out contacts + value: + action: "optOut" + contactIds: ["507f1f77bcf86cd799439011"] + responses: + '200': + description: Bulk update completed + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + action: { type: string } + modified: { type: integer, description: Number of contacts modified } + matched: { type: integer, description: Number of contacts matched } + '400': { description: Validation error (invalid action, missing required fields) } + '401': { $ref: '#/components/responses/Unauthorized' } + delete: + operationId: bulkDeleteWhatsAppContacts + tags: [WhatsApp] + summary: Bulk delete contacts + description: Permanently delete multiple contacts at once (max 500 per request). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - contactIds + properties: + contactIds: + type: array + maxItems: 500 + items: { type: string } + description: Contact IDs to delete (max 500) + example: + contactIds: ["507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012"] + responses: + '200': + description: Bulk delete completed + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + deleted: { type: integer, description: Number of contacts deleted } + '400': { description: contactIds array is required } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ────────────────────────────────────────────────────────────────────────── + # GROUPS + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/groups: + get: + operationId: getWhatsAppGroups + tags: [WhatsApp] + summary: List contact groups + description: | + List all contact groups for a WhatsApp account with contact counts. + Groups are derived from the groups field on contacts, not stored as separate documents. + security: + - bearerAuth: [] + parameters: + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + responses: + '200': + description: Groups retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + groups: + type: array + items: + type: object + properties: + name: { type: string } + totalCount: { type: integer, description: Total contacts in this group } + optedInCount: { type: integer, description: Opted-in contacts in this group } + summary: + type: object + properties: + totalContacts: { type: integer } + optedInContacts: { type: integer } + groupCount: { type: integer } + '400': { description: accountId is required } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + post: + operationId: renameWhatsAppGroup + tags: [WhatsApp] + summary: Rename group + description: Rename a contact group. This updates the group name on all contacts that belong to the group. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - oldName + - newName + properties: + accountId: + type: string + description: WhatsApp social account ID + oldName: + type: string + description: Current group name + newName: + type: string + description: New group name + example: + accountId: "507f1f77bcf86cd799439011" + oldName: "customers" + newName: "active-customers" + responses: + '200': + description: Group renamed successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + modified: { type: integer, description: Number of contacts updated } + '400': { description: Validation error (missing fields, same name) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + delete: + operationId: deleteWhatsAppGroup + tags: [WhatsApp] + summary: Delete group + description: Delete a contact group. This removes the group from all contacts but does not delete the contacts themselves. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - groupName + properties: + accountId: + type: string + description: WhatsApp social account ID + groupName: + type: string + description: Group name to delete + example: + accountId: "507f1f77bcf86cd799439011" + groupName: "old-leads" + responses: + '200': + description: Group deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + modified: { type: integer, description: Number of contacts updated } + '400': { description: Validation error (missing fields) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + # ────────────────────────────────────────────────────────────────────────── + # TEMPLATES + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/templates: + get: + operationId: getWhatsAppTemplates + tags: [WhatsApp] + summary: List templates + description: | + List all message templates for the WhatsApp Business Account (WABA) associated with the given account. + Templates are fetched directly from the WhatsApp Cloud API. + security: + - bearerAuth: [] + parameters: + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + responses: + '200': + description: Templates retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + templates: + type: array + items: + type: object + properties: + id: { type: string, description: WhatsApp template ID } + name: { type: string } + status: { type: string, enum: [APPROVED, PENDING, REJECTED] } + category: { type: string, enum: [AUTHENTICATION, MARKETING, UTILITY] } + language: { type: string } + components: + type: array + items: + type: object + '400': { description: accountId is required or WABA ID not found } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + post: + operationId: createWhatsAppTemplate + tags: [WhatsApp] + summary: Create template + description: | + Create a new message template. Supports two modes: + + **Custom template:** Provide `components` with your own content. Submitted to Meta for review (can take up to 24h). + + **Library template:** Provide `library_template_name` instead of `components` to use a pre-built template + from Meta's template library. Library templates are **pre-approved** (no review wait). You can optionally + customize parameters and buttons via `library_template_body_inputs` and `library_template_button_inputs`. + + Browse available library templates at: https://business.facebook.com/wa/manage/message-templates/ + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - name + - category + - language + properties: + accountId: + type: string + description: WhatsApp social account ID + name: + type: string + pattern: "^[a-z][a-z0-9_]*$" + description: Template name (lowercase, letters/numbers/underscores, must start with a letter) + category: + type: string + enum: [AUTHENTICATION, MARKETING, UTILITY] + description: Template category + language: + type: string + description: Template language code (e.g., en_US) + components: + type: array + description: "Template components (header, body, footer, buttons). Required for custom templates, omit when using library_template_name." + items: + type: object + library_template_name: + type: string + description: | + Name of a pre-built template from Meta's template library (e.g., "appointment_reminder", + "auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved + by Meta with no review wait. Omit `components` when using this field. + library_template_body_inputs: + type: object + description: | + Optional body customizations for library templates. Available options depend on the + template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation, + add_track_package_link, code_expiration_minutes). + library_template_button_inputs: + type: array + description: | + Optional button customizations for library templates. Each item specifies button type + and configuration (e.g., URL, phone number, quick reply). + items: + type: object + properties: + type: + type: string + enum: [QUICK_REPLY, URL, PHONE_NUMBER] + url: + type: object + properties: + base_url: { type: string } + phone_number: + type: string + examples: + custom: + summary: Custom template (requires review) + value: + accountId: "507f1f77bcf86cd799439011" + name: "order_confirmation" + category: "UTILITY" + language: "en_US" + components: + - type: "body" + text: "Your order {{1}} has been confirmed. Expected delivery: {{2}}" + library: + summary: Library template (pre-approved, no review) + value: + accountId: "507f1f77bcf86cd799439011" + name: "my_appointment_reminder" + category: "UTILITY" + language: "en_US" + library_template_name: "appointment_reminder" + library_template_button_inputs: + - type: "URL" + url: + base_url: "https://myapp.com/appointments/{{1}}" + responses: + '200': + description: Template created (pre-approved for library templates, pending review for custom) + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + template: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string, description: "APPROVED for library templates, PENDING for custom" } + category: { type: string } + language: { type: string } + '400': { description: Validation error (invalid name format, missing fields, invalid category) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + /v1/whatsapp/templates/{templateName}: + get: + operationId: getWhatsAppTemplate + tags: [WhatsApp] + summary: Get template + description: Retrieve a single message template by name. + security: + - bearerAuth: [] + parameters: + - name: templateName + in: path + required: true + description: Template name + schema: + type: string + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + responses: + '200': + description: Template retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + template: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string } + category: { type: string } + language: { type: string } + components: + type: array + items: + type: object + '400': { description: accountId is required } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + patch: + operationId: updateWhatsAppTemplate + tags: [WhatsApp] + summary: Update template + description: | + Update a message template's components. Only certain fields can be updated depending on + the template's current approval state. Approved templates can only have components updated. + security: + - bearerAuth: [] + parameters: + - name: templateName + in: path + required: true + description: Template name + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - components + properties: + accountId: + type: string + description: WhatsApp social account ID + components: + type: array + description: Updated template components + items: + type: object + example: + accountId: "507f1f77bcf86cd799439011" + components: + - type: "body" + text: "Updated: Your order {{1}} is confirmed. Delivery by {{2}}" + responses: + '200': + description: Template updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + template: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string } + '400': { description: Validation error (missing fields) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteWhatsAppTemplate + tags: [WhatsApp] + summary: Delete template + description: Permanently delete a message template by name. + security: + - bearerAuth: [] + parameters: + - name: templateName + in: path + required: true + description: Template name + schema: + type: string + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + responses: + '200': + description: Template deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: true + message: "Template \"order_confirmation\" deleted successfully" + '400': { description: accountId or template name is required } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + # ────────────────────────────────────────────────────────────────────────── + # BROADCASTS + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/broadcasts: + get: + operationId: getWhatsAppBroadcasts + tags: [WhatsApp] + summary: List broadcasts + description: | + List all WhatsApp broadcasts for an account. Returns broadcasts sorted by creation date + (newest first) without the full recipients list for performance. + security: + - bearerAuth: [] + parameters: + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + - name: status + in: query + required: false + description: Filter by broadcast status + schema: + type: string + enum: [draft, scheduled, sending, completed, failed, cancelled] + - name: limit + in: query + required: false + description: Maximum results (default 50) + schema: + type: integer + default: 50 + - name: skip + in: query + required: false + description: Offset for pagination + schema: + type: integer + default: 0 + responses: + '200': + description: Broadcasts retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + broadcasts: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + description: { type: string } + template: + type: object + properties: + name: { type: string } + language: { type: string } + status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] } + recipientCount: { type: integer } + scheduledAt: { type: string, format: date-time } + startedAt: { type: string, format: date-time } + completedAt: { type: string, format: date-time } + sentCount: { type: integer } + deliveredCount: { type: integer } + readCount: { type: integer } + failedCount: { type: integer } + createdAt: { type: string, format: date-time } + pagination: + type: object + properties: + total: { type: integer } + limit: { type: integer } + skip: { type: integer } + hasMore: { type: boolean } + '400': { description: accountId is required } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + post: + operationId: createWhatsAppBroadcast + tags: [WhatsApp] + summary: Create broadcast + description: | + Create a new draft broadcast. Optionally include initial recipients. + After creation, add recipients and then send or schedule the broadcast. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + - name + - template + properties: + accountId: + type: string + description: WhatsApp social account ID + name: + type: string + description: Broadcast name + description: + type: string + description: Broadcast description + template: + type: object + required: + - name + - language + properties: + name: + type: string + description: Template name + language: + type: string + description: Template language code + components: + type: array + description: Base template components + items: + type: object + recipients: + type: array + description: Initial recipients (optional) + items: + type: object + required: + - phone + properties: + phone: + type: string + description: Phone number in E.164 format + name: + type: string + variables: + type: object + additionalProperties: { type: string } + description: Per-recipient template variables + example: + accountId: "507f1f77bcf86cd799439011" + name: "Weekly Newsletter" + description: "Weekly product updates" + template: + name: "weekly_update" + language: "en_US" + recipients: + - phone: "+1234567890" + name: "John" + variables: { "1": "John" } + responses: + '200': + description: Broadcast created as draft + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + broadcast: + type: object + properties: + id: { type: string } + name: { type: string } + description: { type: string } + template: { type: object } + status: { type: string, description: Always "draft" for new broadcasts } + recipientCount: { type: integer } + createdAt: { type: string, format: date-time } + '400': { description: Validation error (missing name, template, etc.) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + /v1/whatsapp/broadcasts/{broadcastId}: + get: + operationId: getWhatsAppBroadcast + tags: [WhatsApp] + summary: Get broadcast + description: Retrieve detailed information about a single broadcast including delivery statistics. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + responses: + '200': + description: Broadcast retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + broadcast: + type: object + properties: + id: { type: string } + name: { type: string } + description: { type: string } + template: { type: object } + status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] } + recipientCount: { type: integer } + scheduledAt: { type: string, format: date-time } + startedAt: { type: string, format: date-time } + completedAt: { type: string, format: date-time } + sentCount: { type: integer } + deliveredCount: { type: integer } + readCount: { type: integer } + failedCount: { type: integer } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteWhatsAppBroadcast + tags: [WhatsApp] + summary: Delete broadcast + description: Delete a broadcast. Only draft or cancelled broadcasts can be deleted. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + responses: + '200': + description: Broadcast deleted successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: true + message: "Broadcast deleted successfully" + '400': { description: Can only delete draft or cancelled broadcasts } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /v1/whatsapp/broadcasts/{broadcastId}/send: + post: + operationId: sendWhatsAppBroadcast + tags: [WhatsApp] + summary: Send broadcast + description: | + Start sending a broadcast immediately. The broadcast must be in draft or scheduled status + and have at least one recipient. Messages are sent sequentially with rate limiting. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + responses: + '200': + description: Broadcast send completed + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + status: { type: string, enum: [completed, failed], description: Final broadcast status } + sent: { type: integer, description: Number of messages sent successfully } + failed: { type: integer, description: Number of messages that failed } + total: { type: integer, description: Total recipient count } + example: + success: true + status: "completed" + sent: 95 + failed: 5 + total: 100 + '400': { description: Invalid broadcast status or no recipients } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /v1/whatsapp/broadcasts/{broadcastId}/schedule: + post: + operationId: scheduleWhatsAppBroadcast + tags: [WhatsApp] + summary: Schedule broadcast + description: | + Schedule a draft broadcast for future sending. The scheduled time must be in the future + and no more than 30 days in advance. The broadcast must be in draft status and have recipients. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - scheduledAt + properties: + scheduledAt: + type: string + format: date-time + description: ISO 8601 date-time for sending (must be in the future, max 30 days) + example: + scheduledAt: "2026-03-15T10:00:00Z" + responses: + '200': + description: Broadcast scheduled successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + broadcast: + type: object + properties: + id: { type: string } + status: { type: string, description: "\"scheduled\"" } + scheduledAt: { type: string, format: date-time } + '400': { description: Invalid schedule time or broadcast not in draft status } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: cancelWhatsAppBroadcastSchedule + tags: [WhatsApp] + summary: Cancel scheduled broadcast + description: | + Cancel a scheduled broadcast and return it to draft status. Only broadcasts in + scheduled status can be cancelled. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + responses: + '200': + description: Schedule cancelled, broadcast returned to draft + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + broadcast: + type: object + properties: + id: { type: string } + status: { type: string, description: "\"draft\"" } + message: { type: string } + example: + success: true + broadcast: + id: "507f1f77bcf86cd799439011" + status: "draft" + message: "Broadcast returned to draft status" + '400': { description: Broadcast is not scheduled } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /v1/whatsapp/broadcasts/{broadcastId}/recipients: + get: + operationId: getWhatsAppBroadcastRecipients + tags: [WhatsApp] + summary: List recipients + description: | + List recipients of a broadcast with their delivery status. Supports filtering + by delivery status and pagination. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + - name: status + in: query + required: false + description: Filter by recipient delivery status + schema: + type: string + enum: [pending, sent, delivered, read, failed] + - name: limit + in: query + required: false + description: Maximum results (default 100) + schema: + type: integer + default: 100 + - name: skip + in: query + required: false + description: Offset for pagination + schema: + type: integer + default: 0 + responses: + '200': + description: Recipients retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + recipients: + type: array + items: + type: object + properties: + phone: { type: string } + name: { type: string } + variables: { type: object } + status: { type: string, enum: [pending, sent, delivered, read, failed] } + messageId: { type: string } + error: { type: string } + sentAt: { type: string, format: date-time } + deliveredAt: { type: string, format: date-time } + readAt: { type: string, format: date-time } + pagination: + type: object + properties: + total: { type: integer } + limit: { type: integer } + skip: { type: integer } + hasMore: { type: boolean } + summary: + type: object + properties: + total: { type: integer } + pending: { type: integer } + sent: { type: integer } + delivered: { type: integer } + read: { type: integer } + failed: { type: integer } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + patch: + operationId: addWhatsAppBroadcastRecipients + tags: [WhatsApp] + summary: Add recipients + description: | + Add recipients to a draft broadcast. Maximum 1000 recipients per request. + Duplicate phone numbers are automatically skipped. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - recipients + properties: + recipients: + type: array + maxItems: 1000 + description: Recipients to add (max 1000) + items: + type: object + required: + - phone + properties: + phone: + type: string + description: Phone number in E.164 format + name: + type: string + variables: + type: object + additionalProperties: { type: string } + example: + recipients: + - phone: "+1234567890" + name: "John" + variables: { "1": "John" } + - phone: "+0987654321" + name: "Jane" + responses: + '200': + description: Recipients added successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + added: { type: integer, description: Number of new recipients added } + duplicates: { type: integer, description: Number of duplicate phone numbers skipped } + totalRecipients: { type: integer, description: Total recipient count after addition } + '400': { description: Validation error or broadcast not in draft status } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: removeWhatsAppBroadcastRecipients + tags: [WhatsApp] + summary: Remove recipients + description: Remove recipients from a draft broadcast by phone number. + security: + - bearerAuth: [] + parameters: + - name: broadcastId + in: path + required: true + description: Broadcast ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - phones + properties: + phones: + type: array + items: { type: string } + description: Phone numbers to remove + example: + phones: ["+1234567890", "+0987654321"] + responses: + '200': + description: Recipients removed successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + removed: { type: integer, description: Number of recipients removed } + totalRecipients: { type: integer, description: Remaining recipient count } + '400': { description: Validation error or broadcast not in draft status } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + # ────────────────────────────────────────────────────────────────────────── + # BUSINESS PROFILE + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/business-profile: + get: + operationId: getWhatsAppBusinessProfile + tags: [WhatsApp] + summary: Get business profile + description: Retrieve the WhatsApp Business profile for the account (about, address, description, email, websites, etc.). + security: + - bearerAuth: [] + parameters: + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + responses: + '200': + description: Business profile retrieved successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + businessProfile: + type: object + properties: + about: { type: string, description: Short description (max 139 chars) } + address: { type: string } + description: { type: string, description: Full description (max 512 chars) } + email: { type: string } + profilePictureUrl: { type: string, format: uri } + websites: + type: array + items: { type: string } + maxItems: 2 + vertical: { type: string, description: Business category } + '400': { description: accountId is required or phone number ID not found } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + post: + operationId: updateWhatsAppBusinessProfile + tags: [WhatsApp] + summary: Update business profile + description: | + Update the WhatsApp Business profile. All fields are optional; only provided fields will be updated. + Constraints: about max 139 chars, description max 512 chars, max 2 websites. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + properties: + accountId: + type: string + description: WhatsApp social account ID + about: + type: string + maxLength: 139 + description: Short business description (max 139 characters) + address: + type: string + description: Business address + description: + type: string + maxLength: 512 + description: Full business description (max 512 characters) + email: + type: string + format: email + description: Business email + websites: + type: array + maxItems: 2 + items: { type: string, format: uri } + description: Business websites (max 2) + vertical: + type: string + description: Business category (e.g., RETAIL, ENTERTAINMENT, etc.) + profilePictureHandle: + type: string + description: Handle from resumable upload for profile picture + example: + accountId: "507f1f77bcf86cd799439011" + about: "We help businesses grow" + description: "Premium business solutions for startups and enterprises" + email: "hello@example.com" + websites: ["https://example.com"] + responses: + '200': + description: Business profile updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + example: + success: true + message: "Business profile updated successfully" + '400': { description: Validation error (field too long, too many websites, etc.) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + /v1/whatsapp/business-profile/photo: + post: + operationId: uploadWhatsAppProfilePhoto + tags: [WhatsApp] + summary: Upload profile picture + description: | + Upload a new profile picture for the WhatsApp Business Profile. + Uses Meta's resumable upload API under the hood: creates an upload session, + uploads the image bytes, then updates the business profile with the resulting handle. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [accountId, file] + properties: + accountId: + type: string + description: WhatsApp social account ID + file: + type: string + format: binary + description: Image file (JPEG or PNG, max 5MB, recommended 640x640) + responses: + '200': + description: Profile picture updated successfully + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + '400': { description: Invalid file type, file too large, or missing parameters } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + /v1/whatsapp/business-profile/display-name: + get: + operationId: getWhatsAppDisplayName + tags: [WhatsApp] + summary: Get display name and review status + description: | + Fetch the current display name and its Meta review status for a WhatsApp Business account. + Display name changes require Meta approval and can take 1-3 business days. + security: + - bearerAuth: [] + parameters: + - name: accountId + in: query + required: true + description: WhatsApp social account ID + schema: + type: string + responses: + '200': + description: Display name info retrieved + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + displayName: + type: object + properties: + name: { type: string, description: Current verified display name } + status: + type: string + enum: [APPROVED, PENDING_REVIEW, DECLINED, NONE] + description: Meta review status for the display name + phoneNumber: { type: string, description: Display phone number } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + post: + operationId: updateWhatsAppDisplayName + tags: [WhatsApp] + summary: Request display name change + description: | + Submit a display name change request for the WhatsApp Business account. + The new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business). + Changes require Meta review and approval, which typically takes 1-3 business days. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accountId, displayName] + properties: + accountId: + type: string + description: WhatsApp social account ID + displayName: + type: string + minLength: 3 + maxLength: 512 + description: New display name (must follow WhatsApp naming guidelines) + example: + accountId: "507f1f77bcf86cd799439011" + displayName: "My Business Name" + responses: + '200': + description: Display name change submitted for review + content: + application/json: + schema: + type: object + properties: + success: { type: boolean } + message: { type: string } + displayName: + type: object + properties: + name: { type: string } + status: { type: string, enum: [PENDING_REVIEW] } + '400': { description: Invalid display name (too short, too long, or missing) } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { description: WhatsApp account not found } + + # ────────────────────────────────────────────────────────────────────────── + # PHONE NUMBERS + # ────────────────────────────────────────────────────────────────────────── + + /v1/whatsapp/phone-numbers: + get: + operationId: getWhatsAppPhoneNumbers + tags: [WhatsApp Phone Numbers] + summary: List phone numbers + description: | + List all WhatsApp phone numbers purchased by the authenticated user. + By default, released numbers are excluded. + security: + - bearerAuth: [] + parameters: + - name: status + in: query + required: false + description: Filter by status (by default excludes released numbers) + schema: + type: string + enum: [provisioning, active, suspended, releasing, released] + - name: profileId + in: query + required: false + description: Filter by profile + schema: + type: string + responses: + '200': + description: Phone numbers retrieved successfully + content: + application/json: + schema: + type: object + properties: + numbers: + type: array + items: + type: object + properties: + _id: { type: string } + phoneNumber: { type: string } + country: { type: string } + status: { type: string, enum: [pending_payment, provisioning, active, suspended, releasing, released] } + profileId: { type: object } + provisionedAt: { type: string, format: date-time } + metaPreverifiedId: { type: string } + metaVerificationStatus: { type: string } + createdAt: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + + /v1/whatsapp/phone-numbers/purchase: + post: + operationId: purchaseWhatsAppPhoneNumber + tags: [WhatsApp Phone Numbers] + summary: Purchase phone number + description: | + Initiate purchasing a WhatsApp phone number. Payment-first flow: the user does not pick + a specific number. The system either creates a Stripe Checkout Session (first number) + or increments the existing subscription quantity and provisions inline (subsequent numbers). + + Requires a paid plan. The maximum number of phone numbers is determined by the user's plan. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - profileId + properties: + profileId: + type: string + description: Profile to associate the number with + example: + profileId: "507f1f77bcf86cd799439011" + responses: + '200': + description: | + Either a checkout URL (first number) or the provisioned phone number (subsequent numbers). + content: + application/json: + schema: + oneOf: + - type: object + description: Checkout session created (first number) + properties: + message: { type: string } + checkoutUrl: { type: string, format: uri } + - type: object + description: Phone number provisioned inline (subsequent numbers) + properties: + message: { type: string } + phoneNumber: + type: object + properties: + id: { type: string } + phoneNumber: { type: string } + status: { type: string } + country: { type: string } + provisionedAt: { type: string, format: date-time } + metaPreverifiedId: { type: string } + metaVerificationStatus: { type: string } + '400': { description: Plan limit reached or profileId required } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { description: A paid plan is required } + + /v1/whatsapp/phone-numbers/{phoneNumberId}: + get: + operationId: getWhatsAppPhoneNumber + tags: [WhatsApp Phone Numbers] + summary: Get phone number + description: | + Retrieve the current status of a purchased phone number. Used to poll for + Meta pre-verification completion after purchase. + security: + - bearerAuth: [] + parameters: + - name: phoneNumberId + in: path + required: true + description: Phone number record ID + schema: + type: string + responses: + '200': + description: Phone number retrieved successfully + content: + application/json: + schema: + type: object + properties: + phoneNumber: + type: object + properties: + id: { type: string } + phoneNumber: { type: string } + status: { type: string, enum: [pending_payment, provisioning, active, suspended, releasing, released] } + country: { type: string } + metaPreverifiedId: { type: string } + metaVerificationStatus: { type: string } + provisionedAt: { type: string, format: date-time } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: releaseWhatsAppPhoneNumber + tags: [WhatsApp Phone Numbers] + summary: Release phone number + description: | + Release a purchased phone number. This will: + 1. Disconnect any linked WhatsApp social account + 2. Decrement the Stripe subscription quantity (or cancel if last number) + 3. Release the number from Telnyx + 4. Mark the number as released + security: + - bearerAuth: [] + parameters: + - name: phoneNumberId + in: path + required: true + description: Phone number record ID + schema: + type: string + responses: + '200': + description: Phone number released successfully + content: + application/json: + schema: + type: object + properties: + message: { type: string } + phoneNumber: + type: object + properties: + id: { type: string } + phoneNumber: { type: string } + status: { type: string, description: "\"released\"" } + releasedAt: { type: string, format: date-time } + '400': { description: Phone number is already released or being released } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + diff --git a/pyproject.toml b/pyproject.toml index 8e7b443..4a01a4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,29 +1,38 @@ [project] name = "late-sdk" -version = "1.1.1" -description = "Python SDK for Late API - Social Media Scheduling" +version = "1.2.92" +description = "The official Python library for the Zernio API" readme = "README.md" requires-python = ">=3.10" -license = { text = "MIT" } +license = { text = "Apache-2.0" } authors = [ - { name = "Late", email = "hello@getlate.dev" } + { name = "Zernio", email = "hello@zernio.com" } ] keywords = [ + "zernio", "late", "social-media", "scheduling", - "api-client", - "twitter", + "api", + "sdk", "instagram", - "linkedin", "tiktok", "youtube", + "linkedin", + "twitter", + "x", "facebook", + "pinterest", + "threads", + "bluesky", + "reddit", + "snapchat", + "telegram", ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -54,6 +63,8 @@ all = [ ] mcp = [ "mcp>=1.0.0", + "starlette>=0.42.0", + "uvicorn[standard]>=0.32.0", ] dev = [ "pytest>=8.0.0", @@ -63,16 +74,23 @@ dev = [ "ruff>=0.8.0", "mypy>=1.13.0", "datamodel-code-generator>=0.26.0", + "pyyaml>=6.0.0", ] [project.scripts] -late-mcp = "late.mcp.server:mcp.run" +# New Zernio-branded entry points +zernio-mcp = "late.mcp.server:main" +zernio-mcp-http = "late.mcp.http_server:main" +# Backward-compatible entry points (kept for existing users) +late-mcp = "late.mcp.server:main" +late-mcp-http = "late.mcp.http_server:main" [project.urls] -Homepage = "https://getlate.dev" -Documentation = "https://docs.getlate.dev" -Repository = "https://github.com/getlatedev/late-python-sdk" -Issues = "https://github.com/getlatedev/late-python-sdk/issues" +Homepage = "https://zernio.com" +Documentation = "https://docs.zernio.com" +Repository = "https://github.com/zernio-dev/zernio-python" +Issues = "https://github.com/zernio-dev/zernio-python/issues" +Changelog = "https://docs.zernio.com/changelog" [build-system] requires = ["hatchling"] @@ -114,8 +132,10 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"**/models/_generated/*" = ["UP006", "UP007", "UP035", "W291"] # Allow old-style annotations and trailing whitespace in generated code -"**/models/_generated/**" = ["UP006", "UP007", "UP035", "W291"] +"**/models/_generated/*" = ["UP006", "UP007", "UP035", "W291", "TCH003"] # Allow old-style annotations, trailing whitespace, and non-TYPE_CHECKING imports in generated code +"**/models/_generated/**" = ["UP006", "UP007", "UP035", "W291", "TCH003"] +"**/resources/_generated/*" = ["UP006", "UP007", "UP035", "W291", "ARG002"] # Allow old-style annotations in generated resources +"**/resources/_generated/**" = ["UP006", "UP007", "UP035", "W291", "ARG002"] [tool.ruff.lint.isort] known-first-party = ["late"] diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..4ec1d67 --- /dev/null +++ b/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 100 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 diff --git a/scripts/generate_examples.py b/scripts/generate_examples.py new file mode 100644 index 0000000..23b4f9d --- /dev/null +++ b/scripts/generate_examples.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Generate example code snippets from OpenAPI spec. + +This script parses the OpenAPI spec and generates example code +for common operations based on request/response schemas. +""" + +import re +import sys +from pathlib import Path +from typing import Any + +import yaml + + +def load_openapi_spec(spec_path: Path) -> dict: + """Load and parse the OpenAPI spec.""" + with open(spec_path) as f: + return yaml.safe_load(f) + + +def get_example_value(schema: dict, name: str = "") -> Any: + """Generate an example value from a schema.""" + if "example" in schema: + return schema["example"] + + if "enum" in schema: + return schema["enum"][0] + + schema_type = schema.get("type", "string") + + if schema_type == "string": + if "format" in schema: + fmt = schema["format"] + if fmt == "date-time": + return "2025-02-01T10:00:00Z" + if fmt == "uri": + return "https://example.com/media.mp4" + if "accountId" in name.lower(): + return "acc_xxx" + if "id" in name.lower(): + return "post_xxx" + return "example_value" + elif schema_type == "integer": + return 10 + elif schema_type == "number": + return 1.5 + elif schema_type == "boolean": + return True + elif schema_type == "array": + items = schema.get("items", {}) + return [get_example_value(items, name)] + elif schema_type == "object": + props = schema.get("properties", {}) + return {k: get_example_value(v, k) for k, v in props.items()} + + return "value" + + +def resolve_ref(spec: dict, ref: str) -> dict: + """Resolve a $ref to its schema.""" + if not ref.startswith("#/"): + return {} + parts = ref[2:].split("/") + result = spec + for part in parts: + result = result.get(part, {}) + return result + + +def get_request_body_schema(spec: dict, operation: dict) -> dict | None: + """Extract request body schema from an operation.""" + request_body = operation.get("requestBody", {}) + content = request_body.get("content", {}) + json_content = content.get("application/json", {}) + schema = json_content.get("schema", {}) + + if "$ref" in schema: + schema = resolve_ref(spec, schema["$ref"]) + + return schema if schema else None + + +def generate_create_post_example(spec: dict) -> str: + """Generate create post example from OpenAPI spec.""" + # Find the createPost operation + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if operation.get("operationId") == "createPost": + schema = get_request_body_schema(spec, operation) + if not schema: + break + + # Generate Python example + return '''```python +post = late.posts.create( + content="Hello world from Late!", + platforms=[ + {"platform": "twitter", "accountId": "acc_xxx"}, + {"platform": "linkedin", "accountId": "acc_yyy"}, + {"platform": "instagram", "accountId": "acc_zzz"}, + ], + publish_now=True, +) + +print(f"Published to {len(post['post']['platforms'])} platforms!") +```''' + + return "" + + +def generate_schedule_post_example(spec: dict) -> str: + """Generate schedule post example.""" + return '''```python +post = late.posts.create( + content="This post will go live tomorrow at 10am", + platforms=[{"platform": "instagram", "accountId": "acc_xxx"}], + scheduled_for="2025-02-01T10:00:00Z", +) +```''' + + +def generate_upload_media_example(spec: dict) -> str: + """Generate upload media example.""" + return '''```python +# Option 1: Direct upload (simplest) +result = late.media.upload("path/to/video.mp4") +media_url = result["publicUrl"] + +# Option 2: Upload from bytes +result = late.media.upload_bytes(video_bytes, "video.mp4", "video/mp4") +media_url = result["publicUrl"] + +# Create post with media +post = late.posts.create( + content="Check out this video!", + media_urls=[media_url], + platforms=[ + {"platform": "tiktok", "accountId": "acc_xxx"}, + {"platform": "youtube", "accountId": "acc_yyy", "youtubeTitle": "My Video"}, + ], + publish_now=True, +) +```''' + + +def generate_examples_markdown(spec: dict) -> str: + """Generate the Examples section for README.""" + lines = [ + "## Examples", + "", + "### Schedule a Post", + "", + generate_schedule_post_example(spec), + "", + "### Platform-Specific Content", + "", + "Customize content per platform while posting to all at once:", + "", + '''```python +post = late.posts.create( + content="Default content", + platforms=[ + { + "platform": "twitter", + "accountId": "acc_twitter", + "platformSpecificContent": "Short & punchy for X", + }, + { + "platform": "linkedin", + "accountId": "acc_linkedin", + "platformSpecificContent": "Professional tone for LinkedIn with more detail.", + }, + ], + publish_now=True, +) +```''', + "", + "### Upload Media", + "", + generate_upload_media_example(spec), + "", + "### Get Analytics", + "", + '''```python +data = late.analytics.get(period="30d") + +print("Analytics:", data) +```''', + "", + "### List Connected Accounts", + "", + '''```python +data = late.accounts.list() + +for account in data["accounts"]: + print(f"{account['platform']}: @{account['username']}") +```''', + "", + "### Async Support", + "", + '''```python +import asyncio +from late import Late + +async def main(): + async with Late(api_key="your-api-key") as late: + posts = await late.posts.alist(status="scheduled") + print(f"Found {len(posts['posts'])} scheduled posts") + +asyncio.run(main()) +```''', + ] + + return "\n".join(lines) + + +def main(): + script_dir = Path(__file__).parent + spec_path = script_dir.parent / "openapi.yaml" + + spec = load_openapi_spec(spec_path) + + if "--print" in sys.argv: + print(generate_examples_markdown(spec)) + else: + print("Use --print to see generated examples") + print("This script generates example snippets from OpenAPI schemas") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_mcp_tools.py b/scripts/generate_mcp_tools.py new file mode 100644 index 0000000..5847cd8 --- /dev/null +++ b/scripts/generate_mcp_tools.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +""" +Auto-generates MCP tool handlers from OpenAPI spec. + +This script parses the OpenAPI spec and generates complete MCP tool handlers +that wrap the SDK resources. The generated code can be imported directly +into server.py. + +Usage: + python scripts/generate_mcp_tools.py + # or + uv run python scripts/generate_mcp_tools.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Any + +import yaml + +# Map OpenAPI tags to SDK resource names +TAG_TO_RESOURCE: dict[str, str] = { + "Posts": "posts", + "Accounts": "accounts", + "Profiles": "profiles", + "Analytics": "analytics", + "Account Groups": "account_groups", + "Queue": "queue", + "Webhooks": "webhooks", + "API Keys": "api_keys", + "Media": "media", + "Tools": "tools", + "Users": "users", + "Usage": "usage", + "Logs": "logs", + "Connect": "connect", + "Reddit Search": "reddit", + "Invites": "invites", + "GMB Reviews": "accounts", + "GMB Food Menus": "accounts", + "GMB Location Details": "accounts", + "GMB Media": "accounts", + "GMB Attributes": "accounts", + "GMB Place Actions": "accounts", + "LinkedIn Mentions": "accounts", +} + +# Operations to SKIP (not useful for MCP) +SKIP_OPERATIONS = { + # OAuth redirect endpoints + "connectPlatform", + "startBlueskyConnect", + "completeTiktokAuth", + "startSnapchatConnect", + # Internal endpoints + "deleteUser", + "deleteTeam", + # Already have custom implementations + "createPost", + "retryPost", + "generateMediaUploadToken", + "checkMediaUploadToken", +} + + +def camel_to_snake(name: str) -> str: + """Convert camelCase to snake_case.""" + name = name.replace("-", "_") + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) + name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name) + return name.lower() + + +def get_python_type(schema: dict[str, Any], required: bool = True) -> tuple[str, str]: + """Convert OpenAPI schema to Python type and default value.""" + if not schema: + return "str", '""' + + schema_type = schema.get("type") + default = schema.get("default") + + if schema_type == "string": + type_str = "str" + default_str = f'"{default}"' if default else '""' + elif schema_type == "integer": + type_str = "int" + default_str = str(default) if default is not None else "0" + elif schema_type == "number": + type_str = "float" + default_str = str(default) if default is not None else "0.0" + elif schema_type == "boolean": + type_str = "bool" + default_str = str(default) if default is not None else "False" + elif schema_type == "array": + type_str = "str" # Accept comma-separated + default_str = '""' + else: + type_str = "str" + default_str = '""' + + if not required: + return type_str, default_str + return type_str, "" + + +def extract_parameters(operation: dict[str, Any]) -> list[dict[str, Any]]: + """Extract parameters from operation.""" + params = [] + + for param in operation.get("parameters", []): + if "$ref" in param: + ref = param["$ref"] + if "PageParam" in ref: + params.append({ + "name": "page", + "type": "int", + "required": False, + "default": "1", + "description": "Page number", + "sdk_name": "page", + }) + elif "LimitParam" in ref: + params.append({ + "name": "limit", + "type": "int", + "required": False, + "default": "10", + "description": "Results per page", + "sdk_name": "limit", + }) + continue + + if "name" not in param: + continue + + # Skip header parameters - they're handled differently in SDK + if param.get("in") == "header": + continue + + py_name = camel_to_snake(param["name"]) + # SDK name must also be valid Python identifier + sdk_name = camel_to_snake(param["name"]).replace("-", "_") + # MCP doesn't allow params starting with underscore + if py_name.startswith("_"): + py_name = py_name.lstrip("_") + "_id" if py_name == "_id" else py_name.lstrip("_") + + type_str, default_str = get_python_type( + param.get("schema", {}), + param.get("required", False) + ) + + params.append({ + "name": py_name, + "type": type_str, + "required": param.get("required", False), + "default": default_str, + "description": param.get("description", ""), + "sdk_name": sdk_name, + }) + + # Request body + request_body = operation.get("requestBody", {}) + if request_body: + content = request_body.get("content", {}) + json_content = content.get("application/json", {}) + schema = json_content.get("schema", {}) + properties = schema.get("properties", {}) + required_props = schema.get("required", []) + + for prop_name, prop_schema in properties.items(): + py_name = camel_to_snake(prop_name) + # MCP doesn't allow params starting with underscore + if py_name.startswith("_"): + py_name = py_name.lstrip("_") + if not py_name: # Was just "_" + continue + is_required = prop_name in required_props + type_str, default_str = get_python_type(prop_schema, is_required) + + params.append({ + "name": py_name, + "type": type_str, + "required": is_required, + "default": default_str, + "description": prop_schema.get("description", ""), + "sdk_name": prop_name, + }) + + return params + + +def generate_tool_handler( + tool_name: str, + resource: str, + sdk_method: str, + summary: str, + params: list[dict[str, Any]], +) -> str: + """Generate a complete tool handler function.""" + lines = [] + + # Sort params: required first, then optional + required = [p for p in params if p["required"]] + optional = [p for p in params if not p["required"]] + + # Build function signature + sig_params = [] + for p in required: + sig_params.append(f"{p['name']}: {p['type']}") + for p in optional: + sig_params.append(f"{p['name']}: {p['type']} = {p['default']}") + + sig = ", ".join(sig_params) + + # Docstring - strip trailing whitespace from all lines + doc_lines = [summary.rstrip()] + if params: + doc_lines.append("") + doc_lines.append("Args:") + for p in params: + req = " (required)" if p["required"] else "" + desc = p['description'] if p['description'] else "" + # Strip trailing whitespace from each line of multiline descriptions + desc = "\n".join(line.rstrip() for line in desc.split("\n")) + # Avoid trailing whitespace when description is empty + if desc: + doc_lines.append(f" {p['name']}: {desc}{req}") + else: + doc_lines.append(f" {p['name']}:{req}" if req else f" {p['name']}") + + # Strip trailing whitespace from all docstring lines + docstring = "\n ".join(line.rstrip() for line in doc_lines) + + lines.append("") + lines.append("") + lines.append("@mcp.tool()") + lines.append(f"def {tool_name}({sig}) -> str:") + lines.append(f' """{docstring}"""') + lines.append(" client = _get_client()") + + # Build SDK call - always use keyword args for clarity + sdk_args = [] + for p in params: + sdk_name = p.get("sdk_name", p["name"]) + sdk_args.append(f"{sdk_name}={p['name']}") + + lines.append(f" try:") + lines.append(f" response = client.{resource}.{sdk_method}({', '.join(sdk_args)})") + lines.append(f" return _format_response(response)") + lines.append(f" except Exception as e:") + lines.append(f" return f'Error: {{e}}'") + + return "\n".join(lines) + + +def main() -> int: + """Main entry point.""" + project_root = Path(__file__).parent.parent + openapi_path = project_root / "openapi.yaml" + + if not openapi_path.exists(): + print(f"Error: OpenAPI spec not found at {openapi_path}") + return 1 + + with openapi_path.open() as f: + spec = yaml.safe_load(f) + + # Collect operations + operations = [] + + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method not in ("get", "post", "put", "patch", "delete"): + continue + + operation_id = operation.get("operationId") + if not operation_id or operation_id in SKIP_OPERATIONS: + continue + + tags = operation.get("tags", ["Other"]) + resource = TAG_TO_RESOURCE.get(tags[0], tags[0].lower().replace(" ", "_")) + + # Generate tool name from operationId + sdk_method = camel_to_snake(operation_id) + tool_name = f"{resource}_{sdk_method}" + # Clean up redundant prefixes + tool_name = re.sub(rf"^{resource}_{resource}_", f"{resource}_", tool_name) + + operations.append({ + "tool_name": tool_name, + "resource": resource, + "sdk_method": sdk_method, + "summary": operation.get("summary", operation_id), + "params": extract_parameters(operation), + }) + + # Generate output file + lines = [ + '"""', + "Auto-generated MCP tool handlers.", + "", + "DO NOT EDIT - Run `python scripts/generate_mcp_tools.py` to regenerate.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "", + "def _format_response(response: Any) -> str:", + ' """Format SDK response for MCP output."""', + " if response is None:", + ' return "Success"', + " if hasattr(response, '__dict__'):", + " # Handle response objects", + " if hasattr(response, 'posts') and response.posts:", + " posts = response.posts", + ' lines = [f"Found {len(posts)} post(s):"]', + " for p in posts[:10]:", + " content = str(getattr(p, 'content', ''))[:50]", + " status = getattr(p, 'status', 'unknown')", + ' lines.append(f"- [{status}] {content}...")', + ' return "\\n".join(lines)', + " if hasattr(response, 'accounts') and response.accounts:", + " accs = response.accounts", + ' lines = [f"Found {len(accs)} account(s):"]', + " for a in accs[:10]:", + " platform = getattr(a, 'platform', '?')", + " username = getattr(a, 'username', None) or getattr(a, 'displayName', '?')", + ' lines.append(f"- {platform}: {username}")', + ' return "\\n".join(lines)', + " if hasattr(response, 'profiles') and response.profiles:", + " profiles = response.profiles", + ' lines = [f"Found {len(profiles)} profile(s):"]', + " for p in profiles[:10]:", + " name = getattr(p, 'name', 'Unnamed')", + ' lines.append(f"- {name}")', + ' return "\\n".join(lines)', + " if hasattr(response, 'post') and response.post:", + " p = response.post", + ' return f"Post ID: {getattr(p, \'field_id\', \'N/A\')}\\nStatus: {getattr(p, \'status\', \'N/A\')}"', + " if hasattr(response, 'profile') and response.profile:", + " p = response.profile", + ' return f"Profile: {getattr(p, \'name\', \'N/A\')} (ID: {getattr(p, \'field_id\', \'N/A\')})"', + " return str(response)", + "", + "", + "def register_generated_tools(mcp, _get_client):", + ' """Register all auto-generated tools with the MCP server."""', + ] + + # Group by resource for organization + by_resource: dict[str, list] = {} + for op in operations: + res = op["resource"] + if res not in by_resource: + by_resource[res] = [] + by_resource[res].append(op) + + # Generate handlers inside register function + for resource, ops in sorted(by_resource.items()): + lines.append(f"") + lines.append(f" # {resource.upper()}") + + for op in ops: + handler = generate_tool_handler( + op["tool_name"], + op["resource"], + op["sdk_method"], + op["summary"], + op["params"], + ) + # Indent for being inside register function + handler_lines = handler.split("\n") + for hl in handler_lines: + if hl.strip(): + lines.append(f" {hl}") + else: + lines.append("") + + # Output + output_file = project_root / "src" / "late" / "mcp" / "generated_tools.py" + output_file.write_text("\n".join(lines) + "\n") + + print(f"Generated {output_file}") + print(f"Total tools: {len(operations)}") + print(f"\nTo use: import and call register_generated_tools(mcp, _get_client) in server.py") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_models.py b/scripts/generate_models.py index 574926f..fb12626 100644 --- a/scripts/generate_models.py +++ b/scripts/generate_models.py @@ -23,13 +23,13 @@ def main() -> int: """Generate models from OpenAPI spec.""" # Paths project_root = Path(__file__).parent.parent - openapi_spec = project_root.parent / "late-api-docs" / "public-api.yaml" + openapi_spec = project_root / "openapi.yaml" output_dir = project_root / "src" / "late" / "models" / "_generated" - # Validate OpenAPI spec exists + # Validate OpenAPI spec exists (should be fetched first) if not openapi_spec.exists(): print(f"Error: OpenAPI spec not found at {openapi_spec}") - print("Make sure late-api-docs is in the parent directory.") + print("Run 'curl -o openapi.yaml https://zernio.com/openapi.yaml' first") return 1 # Create output directory @@ -104,6 +104,53 @@ def main() -> int: print("Models generated successfully!") print(f"Output: {output_dir}") print() + + # Validate that the parent models/__init__.py explicit imports + # all exist in the generated models. This catches renames/removals + # in the OpenAPI spec that would break the SDK at import time. + parent_init = output_dir.parent / "__init__.py" + if parent_init.exists(): + print("Validating models/__init__.py imports against generated models...") + generated_source = output_file.read_text() + + import ast + import re + + # Extract all class/enum names from generated models + tree = ast.parse(generated_source) + generated_names = { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.ClassDef) + } + + # Extract explicit import names from the parent __init__.py + # (looks for "from ._generated.models import (...)" blocks) + init_source = parent_init.read_text() + import_names: list[str] = [] + for match in re.finditer( + r"from \._generated\.models import \((.*?)\)", + init_source, + re.DOTALL, + ): + block = match.group(1) + for name in re.findall(r"\b([A-Z]\w+)\b", block): + import_names.append(name) + + missing = [name for name in import_names if name not in generated_names] + if missing: + print() + print("ERROR: models/__init__.py imports names that don't exist in generated models:") + for name in missing: + print(f" - {name}") + print() + print("The OpenAPI spec likely renamed or removed these models.") + print("Update src/late/models/__init__.py to match the generated models.") + return 1 + + print(f" All {len(import_names)} explicit imports verified.") + + print() print("Note: These are base models. Use the curated models in") print("src/late/models/ for the public API.") diff --git a/scripts/generate_readme_reference.py b/scripts/generate_readme_reference.py new file mode 100644 index 0000000..cf6446b --- /dev/null +++ b/scripts/generate_readme_reference.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Generate SDK Reference section for README.md from the OpenAPI spec. + +This script parses the OpenAPI spec and generates markdown tables +documenting all available methods with descriptions from the spec. + +New tags added to the OpenAPI spec are auto-discovered and included +in the README. Only special cases need explicit configuration below. +""" + +import re +import sys +from pathlib import Path + +import yaml + +# Tags that should be merged into another resource instead of getting their own section +TAG_MERGE: dict[str, str] = { + "GMB Reviews": "accounts", + "LinkedIn Mentions": "accounts", +} + +# Tags to skip entirely (no SDK methods) +SKIP_TAGS: set[str] = { + "Inbox Access", +} + +# Override display names (tag -> display name). Unmatched tags use the tag name as-is. +DISPLAY_NAME_OVERRIDES: dict[str, str] = { + "Connect": "Connect (OAuth)", + "Reddit Search": "Reddit", + "Messages": "Messages (Inbox)", + "Comments": "Comments (Inbox)", + "Reviews": "Reviews (Inbox)", +} + +# Override resource key names (tag -> snake_case key). Unmatched tags are auto-converted. +RESOURCE_KEY_OVERRIDES: dict[str, str] = { + "Account Groups": "account_groups", + "API Keys": "api_keys", + "Reddit Search": "reddit", +} + +# Preferred ordering for known resources. Auto-discovered resources appear after these. +PREFERRED_ORDER: list[str] = [ + "posts", + "accounts", + "profiles", + "analytics", + "account_groups", + "queue", + "webhooks", + "api_keys", + "media", + "tools", + "users", + "usage", + "logs", + "connect", + "reddit", +] + +# Resources that should always appear last, in this order +LAST_RESOURCES: list[str] = [ + "invites", +] + +# Additional SDK-only methods not in OpenAPI (helper methods) +SDK_ONLY_METHODS: dict[str, list[tuple[str, str]]] = { + "media": [ + ("upload", "Upload a file from path"), + ("upload_bytes", "Upload file from bytes"), + ("upload_large", "Upload large file with multipart"), + ("upload_large_bytes", "Upload large file from bytes"), + ("upload_multiple", "Upload multiple files"), + ], +} + + +def tag_to_resource_key(tag: str) -> str: + """Convert a tag name to a snake_case resource key. + + e.g. "Account Groups" -> "account_groups", "Messages" -> "messages" + """ + if tag in RESOURCE_KEY_OVERRIDES: + return RESOURCE_KEY_OVERRIDES[tag] + return tag.lower().replace(" ", "_") + + +def camel_to_snake(name: str) -> str: + """Convert camelCase to snake_case.""" + name = name.replace("-", "_") + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) + name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name) + return name.lower() + + +def get_method_sort_key(method_name: str) -> tuple: + """ + Generate a sort key for consistent method ordering. + + Ordering rules (CRUD-style): + 1. list/get_all methods first + 2. bulk/create methods + 3. get (single) methods + 4. update methods + 5. delete methods + 6. Everything else alphabetically + """ + name_lower = method_name.lower() + + if name_lower.startswith("list") or name_lower.startswith("get_all"): + return (0, method_name) + elif name_lower.startswith("bulk") or name_lower.startswith("create"): + return (1, method_name) + elif name_lower.startswith("get") and not name_lower.startswith("get_all"): + return (2, method_name) + elif name_lower.startswith("update"): + return (3, method_name) + elif name_lower.startswith("delete"): + return (4, method_name) + else: + return (5, method_name) + + +def load_openapi_spec(spec_path: Path) -> dict: + """Load and parse the OpenAPI spec.""" + with open(spec_path) as f: + return yaml.safe_load(f) + + +def extract_methods_from_spec( + spec: dict, +) -> tuple[dict[str, list[tuple[str, str]]], list[str], dict[str, str]]: + """ + Extract methods and descriptions from OpenAPI spec. + + Returns (resources, resource_order, display_names). + """ + resources: dict[str, list[tuple[str, str]]] = {} + display_names: dict[str, str] = {} + discovered: set[str] = set() + + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method not in ("get", "post", "put", "patch", "delete"): + continue + + tags = operation.get("tags", []) + if not tags: + continue + + tag = tags[0] + if tag in SKIP_TAGS: + continue + + operation_id = operation.get("operationId", "") + if not operation_id: + continue + + # Resolve the resource key: merged tags go to their parent, others auto-generate + resource_name = TAG_MERGE.get(tag) or tag_to_resource_key(tag) + discovered.add(resource_name) + + # Track display name (non-merged tags only) + if tag not in TAG_MERGE: + display_names[resource_name] = DISPLAY_NAME_OVERRIDES.get(tag, tag) + + if resource_name not in resources: + resources[resource_name] = [] + + # Convert operationId to snake_case method name + method_name = camel_to_snake(operation_id) + + # Use summary as description, or generate from method name + summary = operation.get("summary", "") + description = summary if summary else method_name.replace("_", " ").title() + + resources[resource_name].append((method_name, description)) + + # Add SDK-only methods + for resource_name, methods in SDK_ONLY_METHODS.items(): + if resource_name in resources: + resources[resource_name].extend(methods) + + # Build final order: preferred first, then auto-discovered, then last resources + preferred_set = set(PREFERRED_ORDER) + last_set = set(LAST_RESOURCES) + auto_discovered = sorted( + r for r in discovered if r not in preferred_set and r not in last_set + ) + + resource_order = [ + *[r for r in PREFERRED_ORDER if r in discovered], + *auto_discovered, + *[r for r in LAST_RESOURCES if r in discovered], + ] + + # Sort methods within each resource + for resource_name in resource_order: + if resource_name in resources: + resources[resource_name] = sorted( + resources[resource_name], key=lambda x: get_method_sort_key(x[0]) + ) + + return resources, resource_order, display_names + + +def generate_reference_section( + resources: dict[str, list[tuple[str, str]]], + resource_order: list[str], + display_names: dict[str, str], +) -> str: + """Generate the SDK Reference section markdown.""" + lines = ["## SDK Reference", ""] + + for resource_name in resource_order: + methods = resources.get(resource_name, []) + if not methods: + continue + + display_name = display_names.get(resource_name, resource_name.title()) + + lines.append(f"### {display_name}") + lines.append("| Method | Description |") + lines.append("|--------|-------------|") + + for method_name, description in methods: + lines.append(f"| `{resource_name}.{method_name}()` | {description} |") + + lines.append("") + + return "\n".join(lines) + + +def update_readme(readme_path: Path, reference_section: str) -> None: + """Update the README.md file with the new SDK Reference section.""" + content = readme_path.read_text() + + # Find the SDK Reference section and replace it + # It starts with "## SDK Reference" and ends before "## MCP Server" + pattern = r"## SDK Reference\n.*?(?=## MCP Server)" + replacement = reference_section + "\n" + + new_content = re.sub(pattern, replacement, content, flags=re.DOTALL) + + if new_content != content: + readme_path.write_text(new_content) + print(f"Updated {readme_path}") + else: + print("No changes needed") + + +def main(): + script_dir = Path(__file__).parent + spec_path = script_dir.parent / "openapi.yaml" + readme_path = script_dir.parent / "README.md" + + spec = load_openapi_spec(spec_path) + resources, resource_order, display_names = extract_methods_from_spec(spec) + reference_section = generate_reference_section(resources, resource_order, display_names) + + if "--print" in sys.argv: + print(reference_section) + else: + update_readme(readme_path, reference_section) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_resources.py b/scripts/generate_resources.py new file mode 100644 index 0000000..e093a2a --- /dev/null +++ b/scripts/generate_resources.py @@ -0,0 +1,535 @@ +#!/usr/bin/env python3 +""" +Auto-generates Python resource classes from OpenAPI spec. + +This script parses the OpenAPI spec and generates: +- Resource classes with sync and async methods for each endpoint +- Proper type hints and docstrings +- Snake_case method names from operationIds + +Usage: + python scripts/generate_resources.py + # or + uv run python scripts/generate_resources.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Any + +import yaml + +# Map OpenAPI tags to resource class names +TAG_TO_RESOURCE: dict[str, str] = { + "Posts": "posts", + "Accounts": "accounts", + "Profiles": "profiles", + "Analytics": "analytics", + "Account Groups": "account_groups", + "Queue": "queue", + "Webhooks": "webhooks", + "API Keys": "api_keys", + "Media": "media", + "Tools": "tools", + "Users": "users", + "Usage": "usage", + "Logs": "logs", + "Connect": "connect", + "Reddit Search": "reddit", + "Invites": "invites", + "GMB Reviews": "accounts", # Group under accounts + "GMB Food Menus": "accounts", # Group under accounts + "GMB Location Details": "accounts", # Group under accounts + "GMB Media": "accounts", # Group under accounts + "GMB Attributes": "accounts", # Group under accounts + "GMB Place Actions": "accounts", # Group under accounts + "LinkedIn Mentions": "accounts", # Group under accounts +} + +# Resource descriptions for docstrings +RESOURCE_DESCRIPTIONS: dict[str, str] = { + "posts": "Create, schedule, and manage social media posts", + "accounts": "Manage connected social media accounts", + "profiles": "Manage workspace profiles", + "analytics": "Get performance metrics and analytics", + "account_groups": "Organize accounts into groups", + "queue": "Manage posting queue and time slots", + "webhooks": "Configure event webhooks", + "api_keys": "Manage API keys", + "media": "Upload and manage media files", + "tools": "Media download and utility tools", + "users": "User management", + "usage": "Get usage statistics", + "logs": "Publishing logs for debugging", + "connect": "OAuth connection flows", + "reddit": "Reddit search and feed", + "invites": "Team invitations", +} + + +def camel_to_snake(name: str) -> str: + """Convert camelCase to snake_case.""" + # Replace hyphens with underscores first + name = name.replace("-", "_") + # Handle acronyms like 'URL' -> 'url', 'API' -> 'api' + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) + name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name) + return name.lower() + + +def snake_to_camel(name: str) -> str: + """Convert snake_case to camelCase.""" + components = name.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + +def get_python_type(schema: dict[str, Any], required: bool = True) -> str: + """Convert OpenAPI schema to Python type hint.""" + if not schema: + return "Any" + + schema_type = schema.get("type") + + if schema_type == "string": + if schema.get("format") == "date-time": + base = "datetime | str" + else: + base = "str" + elif schema_type == "integer": + base = "int" + elif schema_type == "number": + base = "float" + elif schema_type == "boolean": + base = "bool" + elif schema_type == "array": + items = schema.get("items", {}) + item_type = get_python_type(items) + base = f"list[{item_type}]" + elif schema_type == "object": + base = "dict[str, Any]" + else: + base = "Any" + + if not required: + return f"{base} | None" + return base + + +def extract_parameters(operation: dict[str, Any]) -> list[dict[str, Any]]: + """Extract parameters from an operation.""" + params = [] + + # Query/path parameters + for param in operation.get("parameters", []): + # Skip $ref parameters (they reference common params we'll handle differently) + if "$ref" in param: + # Handle common parameters by name + ref = param["$ref"] + if "PageParam" in ref: + params.append({ + "name": "page", + "original_name": "page", + "type": "int | None", + "required": False, + "description": "Page number (1-based)", + "in": "query", + "default": 1, + }) + elif "LimitParam" in ref: + params.append({ + "name": "limit", + "original_name": "limit", + "type": "int | None", + "required": False, + "description": "Page size", + "in": "query", + "default": 10, + }) + continue + + if "name" not in param: + continue + + params.append({ + "name": camel_to_snake(param["name"]), + "original_name": param["name"], + "type": get_python_type(param.get("schema", {}), param.get("required", False)), + "required": param.get("required", False), + "description": param.get("description", ""), + "in": param.get("in", "query"), + "default": param.get("schema", {}).get("default"), + }) + + # Request body parameters + request_body = operation.get("requestBody", {}) + if request_body: + content = request_body.get("content", {}) + json_content = content.get("application/json", {}) + schema = json_content.get("schema", {}) + + # Handle properties in request body + properties = schema.get("properties", {}) + required_props = schema.get("required", []) + + for prop_name, prop_schema in properties.items(): + params.append({ + "name": camel_to_snake(prop_name), + "original_name": prop_name, + "type": get_python_type(prop_schema, prop_name in required_props), + "required": prop_name in required_props, + "description": prop_schema.get("description", ""), + "in": "body", + "default": prop_schema.get("default"), + }) + + return params + + +def generate_method_signature( + method_name: str, + params: list[dict[str, Any]], + is_async: bool = False, +) -> str: + """Generate method signature.""" + prefix = "async " if is_async else "" + func_name = f"a{method_name}" if is_async else method_name + + # Separate required and optional parameters + required_params = [p for p in params if p["required"]] + optional_params = [p for p in params if not p["required"]] + + # Build parameter list + param_strs = ["self"] + + # Required parameters first + for param in required_params: + param_strs.append(f"{param['name']}: {param['type']}") + + # Then keyword-only optional parameters + if optional_params: + if required_params: + param_strs.append("*") + else: + param_strs.append("*") + + for param in optional_params: + default = param.get("default") + if default is None: + default_str = "None" + elif isinstance(default, str): + default_str = f'"{default}"' + elif isinstance(default, bool): + default_str = str(default) + else: + default_str = str(default) + + # Clean up type for optional params + param_type = param["type"] + if "| None" not in param_type: + param_type = f"{param_type} | None" + + param_strs.append(f"{param['name']}: {param_type} = {default_str}") + + return f"{prefix}def {func_name}({', '.join(param_strs)})" + + +def generate_method_body( + http_method: str, + path: str, + params: list[dict[str, Any]], + operation_id: str, + is_async: bool = False, +) -> list[str]: + """Generate method body.""" + lines = [] + await_prefix = "await " if is_async else "" + client_method = f"_a{http_method.lower()}" if is_async else f"_{http_method.lower()}" + + # Build query params + query_params = [p for p in params if p["in"] == "query"] + body_params = [p for p in params if p["in"] == "body"] + path_params = [p for p in params if p["in"] == "path"] + + # Handle path parameters + path_expr = f'"{path}"' + if path_params: + # Replace {param} with f-string format + path_formatted = path + for p in path_params: + path_formatted = path_formatted.replace( + "{" + p["original_name"] + "}", + "{" + p["name"] + "}" + ) + path_expr = f'f"{path_formatted}"' + + # Determine if we need query params based on HTTP method + # GET and DELETE use query params; POST/PUT/PATCH typically use body + use_query_params = http_method.upper() in ("GET", "DELETE") and query_params + use_body_params = http_method.upper() in ("POST", "PUT", "PATCH") and body_params + # POST can also have query params in URL + use_query_on_post = http_method.upper() in ("POST", "PUT", "PATCH") and query_params and not body_params + + # Build params dict if needed + if use_query_params or use_query_on_post: + lines.append(" params = self._build_params(") + for p in query_params: + lines.append(f" {p['name']}={p['name']},") + lines.append(" )") + + # Build payload dict if needed + if use_body_params: + lines.append(" payload = self._build_payload(") + for p in body_params: + lines.append(f" {p['name']}={p['name']},") + lines.append(" )") + + # Make the request + if http_method.upper() == "GET": + if query_params: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, params=params)") + else: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr})") + elif http_method.upper() == "DELETE": + if query_params: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, params=params)") + else: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr})") + else: # POST, PUT, PATCH + if body_params: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, data=payload)") + elif query_params: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, params=params)") + else: + lines.append(f" return {await_prefix}self._client.{client_method}({path_expr})") + + return lines + + +def generate_resource_class( + resource_name: str, + operations: list[dict[str, Any]], +) -> str: + """Generate a complete resource class.""" + class_name = "".join(word.title() for word in resource_name.split("_")) + "Resource" + description = RESOURCE_DESCRIPTIONS.get(resource_name, f"{resource_name} operations") + + # Check if any operation uses datetime type + uses_datetime = any( + "datetime" in p.get("type", "") + for op in operations + for p in op.get("params", []) + ) + + lines = [ + '"""', + f"Auto-generated {resource_name} resource.", + "", + "DO NOT EDIT THIS FILE MANUALLY.", + "Run `python scripts/generate_resources.py` to regenerate.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import TYPE_CHECKING, Any", + "", + "if TYPE_CHECKING:", + ] + + # Add datetime import inside TYPE_CHECKING block if needed + if uses_datetime: + lines.append(" from datetime import datetime") + lines.append("") + lines.append(" from ..client.base import BaseClient") + else: + lines.append(" from ..client.base import BaseClient") + + lines.extend([ + "", + "", + f"class {class_name}:", + f' """', + f" {description}.", + f' """', + "", + " def __init__(self, client: BaseClient) -> None:", + " self._client = client", + "", + " def _build_params(self, **kwargs: Any) -> dict[str, Any]:", + ' """Build query parameters, filtering None values."""', + " def to_camel(s: str) -> str:", + ' parts = s.split("_")', + ' return parts[0] + "".join(p.title() for p in parts[1:])', + " return {to_camel(k): v for k, v in kwargs.items() if v is not None}", + "", + " def _build_payload(self, **kwargs: Any) -> dict[str, Any]:", + ' """Build request payload, filtering None values."""', + " from datetime import datetime", + " def to_camel(s: str) -> str:", + ' parts = s.split("_")', + ' return parts[0] + "".join(p.title() for p in parts[1:])', + " result: dict[str, Any] = {}", + " for k, v in kwargs.items():", + " if v is None:", + " continue", + " if isinstance(v, datetime):", + " result[to_camel(k)] = v.isoformat()", + " else:", + " result[to_camel(k)] = v", + " return result", + ]) + + # Generate sync methods + for op in operations: + method_name = camel_to_snake(op["operation_id"]) + params = op["params"] + http_method = op["http_method"] + path = op["path"] + summary = op.get("summary", "") + + lines.append("") + sig = generate_method_signature(method_name, params, is_async=False) + lines.append(f" {sig} -> dict[str, Any]:") + lines.append(f' """{summary}"""') + body_lines = generate_method_body(http_method, path, params, op["operation_id"], is_async=False) + lines.extend(body_lines) + + # Generate async methods + for op in operations: + method_name = camel_to_snake(op["operation_id"]) + params = op["params"] + http_method = op["http_method"] + path = op["path"] + summary = op.get("summary", "") + + lines.append("") + sig = generate_method_signature(method_name, params, is_async=True) + lines.append(f" {sig} -> dict[str, Any]:") + lines.append(f' """{summary} (async)"""') + body_lines = generate_method_body(http_method, path, params, op["operation_id"], is_async=True) + lines.extend(body_lines) + + return "\n".join(lines) + "\n" + + +def generate_init_file(resources: list[str]) -> str: + """Generate __init__.py for resources.""" + lines = [ + '"""', + "Auto-generated resource exports.", + "", + "DO NOT EDIT THIS FILE MANUALLY.", + "Run `python scripts/generate_resources.py` to regenerate.", + '"""', + "", + "from __future__ import annotations", + "", + ] + + # Import each resource + for resource in sorted(resources): + class_name = "".join(word.title() for word in resource.split("_")) + "Resource" + lines.append(f"from ._generated.{resource} import {class_name}") + + lines.append("") + lines.append("__all__ = [") + for resource in sorted(resources): + class_name = "".join(word.title() for word in resource.split("_")) + "Resource" + lines.append(f' "{class_name}",') + lines.append("]") + + return "\n".join(lines) + "\n" + + +def generate_client_resources(resources: list[str]) -> str: + """Generate the resource initialization code for the client.""" + lines = [] + for resource in sorted(resources): + class_name = "".join(word.title() for word in resource.split("_")) + "Resource" + attr_name = resource + lines.append(f" self.{attr_name} = {class_name}(self)") + return "\n".join(lines) + + +def main() -> int: + """Main entry point.""" + project_root = Path(__file__).parent.parent + openapi_path = project_root / "openapi.yaml" + + if not openapi_path.exists(): + print(f"Error: OpenAPI spec not found at {openapi_path}") + print("Run 'curl -o openapi.yaml https://zernio.com/openapi.yaml' first") + return 1 + + with openapi_path.open() as f: + spec = yaml.safe_load(f) + + # Group operations by resource + resources: dict[str, list[dict[str, Any]]] = {} + + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method not in ("get", "post", "put", "patch", "delete"): + continue + + operation_id = operation.get("operationId") + if not operation_id: + print(f"Warning: No operationId for {method.upper()} {path}") + continue + + tags = operation.get("tags", ["Other"]) + primary_tag = tags[0] + resource_name = TAG_TO_RESOURCE.get(primary_tag, primary_tag.lower().replace(" ", "_")) + + if resource_name not in resources: + resources[resource_name] = [] + + resources[resource_name].append({ + "operation_id": operation_id, + "http_method": method, + "path": path, + "summary": operation.get("summary", ""), + "description": operation.get("description", ""), + "params": extract_parameters(operation), + }) + + # Create output directory + output_dir = project_root / "src" / "late" / "resources" / "_generated" + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate each resource file + generated_resources = [] + for resource_name, operations in resources.items(): + code = generate_resource_class(resource_name, operations) + output_file = output_dir / f"{resource_name}.py" + output_file.write_text(code) + generated_resources.append(resource_name) + print(f"Generated {output_file.name} with {len(operations)} methods") + + # Generate __init__.py for _generated + init_content = '"""Auto-generated resources."""\n\nfrom __future__ import annotations\n\n' + for resource in sorted(generated_resources): + class_name = "".join(word.title() for word in resource.split("_")) + "Resource" + init_content += f"from .{resource} import {class_name}\n" + init_content += "\n__all__ = [\n" + for resource in sorted(generated_resources): + class_name = "".join(word.title() for word in resource.split("_")) + "Resource" + init_content += f' "{class_name}",\n' + init_content += "]\n" + + (output_dir / "__init__.py").write_text(init_content) + + print(f"\nGenerated {len(generated_resources)} resource classes") + print(f"Output: {output_dir}") + + # Print resource initialization code for the client + print("\n# Add to Late client __init__:") + print(generate_client_resources(generated_resources)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/late/__init__.py b/src/late/__init__.py index 06b5506..9129da6 100644 --- a/src/late/__init__.py +++ b/src/late/__init__.py @@ -31,7 +31,7 @@ Visibility, ) -__version__ = "1.1.1" +__version__ = "1.2.91" __all__ = [ # Client diff --git a/src/late/client/base.py b/src/late/client/base.py index 787e23c..312d04f 100644 --- a/src/late/client/base.py +++ b/src/late/client/base.py @@ -33,7 +33,7 @@ class BaseClient: automatic retries, and full HTTP/2 support. """ - DEFAULT_BASE_URL = "https://getlate.dev/api" + DEFAULT_BASE_URL = "https://zernio.com/api" DEFAULT_TIMEOUT = 30.0 DEFAULT_MAX_RETRIES = 3 SDK_VERSION = "1.0.0" @@ -51,7 +51,7 @@ def __init__( Args: api_key: Late API key for authentication - base_url: Base URL for the API (default: https://getlate.dev/api) + base_url: Base URL for the API (default: https://zernio.com/api) timeout: Request timeout in seconds (default: 30) max_retries: Maximum retries for failed requests (default: 3) """ diff --git a/src/late/client/late_client.py b/src/late/client/late_client.py index 7a343f8..b35b601 100644 --- a/src/late/client/late_client.py +++ b/src/late/client/late_client.py @@ -5,14 +5,22 @@ from __future__ import annotations from ..resources import ( + AccountGroupsResource, AccountsResource, AnalyticsResource, + ApiKeysResource, + ConnectResource, + InvitesResource, + LogsResource, MediaResource, PostsResource, ProfilesResource, QueueResource, + RedditResource, ToolsResource, + UsageResource, UsersResource, + WebhooksResource, ) from .base import BaseClient @@ -55,7 +63,7 @@ def __init__( Args: api_key: Late API key - base_url: Base URL (default: https://getlate.dev/api) + base_url: Base URL (default: https://zernio.com/api) timeout: Request timeout in seconds max_retries: Maximum retries for failed requests """ @@ -63,7 +71,7 @@ def __init__( api_key, base_url=base_url, timeout=timeout, max_retries=max_retries ) - # Initialize resources + # Core resources (manual with Pydantic validation) self.posts = PostsResource(self) self.profiles = ProfilesResource(self) self.accounts = AccountsResource(self) @@ -73,6 +81,16 @@ def __init__( self.tools = ToolsResource(self) self.queue = QueueResource(self) + # Additional resources (auto-generated) + self.webhooks = WebhooksResource(self) + self.logs = LogsResource(self) + self.connect = ConnectResource(self) + self.invites = InvitesResource(self) + self.api_keys = ApiKeysResource(self) + self.account_groups = AccountGroupsResource(self) + self.usage = UsageResource(self) + self.reddit = RedditResource(self) + async def __aenter__(self) -> Late: """Async context manager entry.""" return self diff --git a/src/late/mcp/auth.py b/src/late/mcp/auth.py new file mode 100644 index 0000000..d933951 --- /dev/null +++ b/src/late/mcp/auth.py @@ -0,0 +1,44 @@ +"""Authentication module for Late MCP HTTP server.""" + +import httpx +from starlette.requests import Request + + +def extract_late_api_key(request: Request) -> str | None: + """ + Extract Late API key from request Authorization header. + + Expects: Authorization: Bearer + + Args: + request: The incoming Starlette request. + + Returns: + The extracted API key, or None if not found. + """ + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + return auth_header[7:] # Remove "Bearer " prefix + return None + + +async def verify_late_api_key(api_key: str) -> bool: + """ + Verify Late API key by making a test request to Late API. + + Args: + api_key: The Late API key to verify. + + Returns: + True if API key is valid, False otherwise. + """ + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://zernio.com/api/v1/accounts", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=5.0, + ) + return response.status_code == 200 + except Exception: + return False diff --git a/src/late/mcp/config.py b/src/late/mcp/config.py new file mode 100644 index 0000000..b06597f --- /dev/null +++ b/src/late/mcp/config.py @@ -0,0 +1,50 @@ +"""Configuration management for Late MCP HTTP server.""" + +import os +from dataclasses import dataclass + +from late.mcp.constants import ( + DEFAULT_HOST, + DEFAULT_PORT, + ENV_HOST, + ENV_PORT, +) + + +@dataclass +class ServerConfig: + """Server configuration.""" + + host: str + port: int + debug: bool = False + + @classmethod + def from_env(cls, host: str | None = None, port: int | None = None, debug: bool = False) -> "ServerConfig": + """ + Create configuration from environment variables. + + Args: + host: Override host from environment + port: Override port from environment + debug: Enable debug mode + + Returns: + ServerConfig instance + """ + return cls( + host=host or os.getenv(ENV_HOST, DEFAULT_HOST), + port=port or int(os.getenv(ENV_PORT, str(DEFAULT_PORT))), + debug=debug, + ) + + +def validate_environment() -> None: + """ + Validate required environment variables are set. + + Note: No environment variables are required for the server. + Users provide their Late API keys via request headers. + """ + # No validation needed - users provide API keys in headers + pass diff --git a/src/late/mcp/constants.py b/src/late/mcp/constants.py new file mode 100644 index 0000000..af88cc7 --- /dev/null +++ b/src/late/mcp/constants.py @@ -0,0 +1,23 @@ +"""Constants for Late MCP HTTP server.""" + +# Server information +SERVICE_NAME = "Late MCP Server" +SERVICE_VERSION = "1.1.2" +TRANSPORT_TYPE = "sse" + +# Default server configuration +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8080 + +# Environment variable names +ENV_HOST = "HOST" +ENV_PORT = "PORT" + +# Endpoints +ENDPOINT_ROOT = "/" +ENDPOINT_HEALTH = "/health" +ENDPOINT_SSE = "/sse" +ENDPOINT_MESSAGES = "/messages/" + +# Documentation +DOCS_URL = "https://docs.zernio.com" diff --git a/src/late/mcp/generated_tools.py b/src/late/mcp/generated_tools.py new file mode 100644 index 0000000..6d88292 --- /dev/null +++ b/src/late/mcp/generated_tools.py @@ -0,0 +1,3788 @@ +""" +Auto-generated MCP tool handlers. + +DO NOT EDIT - Run `python scripts/generate_mcp_tools.py` to regenerate. +""" + +from __future__ import annotations + +from typing import Any + + +def _format_response(response: Any) -> str: + """Format SDK response for MCP output.""" + if response is None: + return "Success" + if hasattr(response, "__dict__"): + # Handle response objects + if hasattr(response, "posts") and response.posts: + posts = response.posts + lines = [f"Found {len(posts)} post(s):"] + for p in posts[:10]: + content = str(getattr(p, "content", ""))[:50] + status = getattr(p, "status", "unknown") + lines.append(f"- [{status}] {content}...") + return "\n".join(lines) + if hasattr(response, "accounts") and response.accounts: + accs = response.accounts + lines = [f"Found {len(accs)} account(s):"] + for a in accs[:10]: + platform = getattr(a, "platform", "?") + username = getattr(a, "username", None) or getattr( + a, "displayName", "?" + ) + lines.append(f"- {platform}: {username}") + return "\n".join(lines) + if hasattr(response, "profiles") and response.profiles: + profiles = response.profiles + lines = [f"Found {len(profiles)} profile(s):"] + for p in profiles[:10]: + name = getattr(p, "name", "Unnamed") + lines.append(f"- {name}") + return "\n".join(lines) + if hasattr(response, "post") and response.post: + p = response.post + return f"Post ID: {getattr(p, 'field_id', 'N/A')}\nStatus: {getattr(p, 'status', 'N/A')}" + if hasattr(response, "profile") and response.profile: + p = response.profile + return f"Profile: {getattr(p, 'name', 'N/A')} (ID: {getattr(p, 'field_id', 'N/A')})" + return str(response) + + +def register_generated_tools(mcp, _get_client): + """Register all auto-generated tools with the MCP server.""" + + # ACCOUNT_GROUPS + + @mcp.tool() + def account_groups_list_account_groups() -> str: + """List groups""" + client = _get_client() + try: + response = client.account_groups.list_account_groups() + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_groups_create_account_group(name: str, account_ids: str) -> str: + """Create group + + Args: + name: (required) + account_ids: (required)""" + client = _get_client() + try: + response = client.account_groups.create_account_group( + name=name, accountIds=account_ids + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_groups_update_account_group( + group_id: str, name: str = "", account_ids: str = "" + ) -> str: + """Update group + + Args: + group_id: (required) + name + account_ids""" + client = _get_client() + try: + response = client.account_groups.update_account_group( + group_id=group_id, name=name, accountIds=account_ids + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_groups_delete_account_group(group_id: str) -> str: + """Delete group + + Args: + group_id: (required)""" + client = _get_client() + try: + response = client.account_groups.delete_account_group(group_id=group_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # ACCOUNT_SETTINGS + + @mcp.tool() + def account_settings_get_messenger_menu(account_id: str) -> str: + """Get FB persistent menu + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.account_settings.get_messenger_menu(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_set_messenger_menu( + account_id: str, persistent_menu: str + ) -> str: + """Set FB persistent menu + + Args: + account_id: (required) + persistent_menu: Persistent menu configuration array (Meta format) (required)""" + client = _get_client() + try: + response = client.account_settings.set_messenger_menu( + account_id=account_id, persistent_menu=persistent_menu + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_delete_messenger_menu(account_id: str) -> str: + """Delete FB persistent menu + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.account_settings.delete_messenger_menu( + account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_get_instagram_ice_breakers(account_id: str) -> str: + """Get IG ice breakers + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.account_settings.get_instagram_ice_breakers( + account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_set_instagram_ice_breakers( + account_id: str, ice_breakers: str + ) -> str: + """Set IG ice breakers + + Args: + account_id: (required) + ice_breakers: (required)""" + client = _get_client() + try: + response = client.account_settings.set_instagram_ice_breakers( + account_id=account_id, ice_breakers=ice_breakers + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_delete_instagram_ice_breakers(account_id: str) -> str: + """Delete IG ice breakers + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.account_settings.delete_instagram_ice_breakers( + account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_get_telegram_commands(account_id: str) -> str: + """Get TG bot commands + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.account_settings.get_telegram_commands( + account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_set_telegram_commands(account_id: str, commands: str) -> str: + """Set TG bot commands + + Args: + account_id: (required) + commands: (required)""" + client = _get_client() + try: + response = client.account_settings.set_telegram_commands( + account_id=account_id, commands=commands + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def account_settings_delete_telegram_commands(account_id: str) -> str: + """Delete TG bot commands + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.account_settings.delete_telegram_commands( + account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # ACCOUNTS + + @mcp.tool() + def accounts_list_accounts( + profile_id: str = "", platform: str = "", include_over_limit: bool = False + ) -> str: + """List accounts + + Args: + profile_id: Filter accounts by profile ID + platform: Filter accounts by platform (e.g. "instagram", "twitter"). + include_over_limit: When true, includes accounts from over-limit profiles.""" + client = _get_client() + try: + response = client.accounts.list_accounts( + profile_id=profile_id, + platform=platform, + include_over_limit=include_over_limit, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_follower_stats( + account_ids: str = "", + profile_id: str = "", + from_date: str = "", + to_date: str = "", + granularity: str = "daily", + ) -> str: + """Get follower stats + + Args: + account_ids: Comma-separated list of account IDs (optional, defaults to all user's accounts) + profile_id: Filter by profile ID + from_date: Start date in YYYY-MM-DD format (defaults to 30 days ago) + to_date: End date in YYYY-MM-DD format (defaults to today) + granularity: Data aggregation level""" + client = _get_client() + try: + response = client.accounts.get_follower_stats( + account_ids=account_ids, + profile_id=profile_id, + from_date=from_date, + to_date=to_date, + granularity=granularity, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_update_account( + account_id: str, username: str = "", display_name: str = "" + ) -> str: + """Update account + + Args: + account_id: (required) + username + display_name""" + client = _get_client() + try: + response = client.accounts.update_account( + account_id=account_id, username=username, displayName=display_name + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_delete_account(account_id: str) -> str: + """Disconnect account + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.accounts.delete_account(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_all_accounts_health( + profile_id: str = "", platform: str = "", status: str = "" + ) -> str: + """Check accounts health + + Args: + profile_id: Filter by profile ID + platform: Filter by platform + status: Filter by health status""" + client = _get_client() + try: + response = client.accounts.get_all_accounts_health( + profile_id=profile_id, platform=platform, status=status + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_account_health(account_id: str) -> str: + """Check account health + + Args: + account_id: The account ID to check (required)""" + client = _get_client() + try: + response = client.accounts.get_account_health(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_google_business_reviews( + account_id: str, + location_id: str = "", + page_size: int = 50, + page_token: str = "", + ) -> str: + """Get reviews + + Args: + account_id: The Zernio account ID (from /v1/accounts) (required) + location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + page_size: Number of reviews to fetch per page (max 50) + page_token: Pagination token from previous response""" + client = _get_client() + try: + response = client.accounts.get_google_business_reviews( + account_id=account_id, + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_google_business_food_menus( + account_id: str, location_id: str = "" + ) -> str: + """Get food menus + + Args: + account_id: The Zernio account ID (from /v1/accounts) (required) + location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.""" + client = _get_client() + try: + response = client.accounts.get_google_business_food_menus( + account_id=account_id, location_id=location_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_update_google_business_food_menus( + account_id: str, menus: str, location_id: str = "", update_mask: str = "" + ) -> str: + """Update food menus + + Args: + account_id: The Zernio account ID (from /v1/accounts) (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + menus: Array of food menus to set (required) + update_mask: Field mask for partial updates (e.g. "menus")""" + client = _get_client() + try: + response = client.accounts.update_google_business_food_menus( + account_id=account_id, + location_id=location_id, + menus=menus, + updateMask=update_mask, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_google_business_location_details( + account_id: str, location_id: str = "", read_mask: str = "" + ) -> str: + """Get location details + + Args: + account_id: The Zernio account ID (from /v1/accounts) (required) + location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + read_mask: Comma-separated fields to return. Available: name, title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, serviceArea, serviceItems, profile, openInfo, metadata, moreHours.""" + client = _get_client() + try: + response = client.accounts.get_google_business_location_details( + account_id=account_id, location_id=location_id, read_mask=read_mask + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_update_google_business_location_details( + account_id: str, + update_mask: str, + location_id: str = "", + regular_hours: str = "", + special_hours: str = "", + profile: str = "", + website_uri: str = "", + phone_numbers: str = "", + categories: str = "", + service_items: str = "", + ) -> str: + """Update location details + + Args: + account_id: The Zernio account ID (from /v1/accounts) (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + update_mask: Required. Comma-separated fields to update (e.g. 'regularHours', 'specialHours', 'profile.description', 'categories', 'serviceItems'). Any valid Google Business Information API updateMask field is supported. (required) + regular_hours + special_hours + profile + website_uri + phone_numbers + categories: Primary and additional business categories. Use updateMask='categories' to update. + service_items: Services offered by the business. Use updateMask='serviceItems' to update.""" + client = _get_client() + try: + response = client.accounts.update_google_business_location_details( + account_id=account_id, + location_id=location_id, + updateMask=update_mask, + regularHours=regular_hours, + specialHours=special_hours, + profile=profile, + websiteUri=website_uri, + phoneNumbers=phone_numbers, + categories=categories, + serviceItems=service_items, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_list_google_business_media( + account_id: str, + location_id: str = "", + page_size: int = 100, + page_token: str = "", + ) -> str: + """List media + + Args: + account_id: (required) + location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + page_size: Number of items to return (max 100) + page_token: Pagination token from previous response""" + client = _get_client() + try: + response = client.accounts.list_google_business_media( + account_id=account_id, + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_create_google_business_media( + account_id: str, + source_url: str, + location_id: str = "", + media_format: str = "PHOTO", + description: str = "", + category: str = "", + ) -> str: + """Upload photo + + Args: + account_id: (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + source_url: Publicly accessible image URL (required) + media_format + description: Photo description + category: Where the photo appears on the listing""" + client = _get_client() + try: + response = client.accounts.create_google_business_media( + account_id=account_id, + location_id=location_id, + sourceUrl=source_url, + mediaFormat=media_format, + description=description, + category=category, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_delete_google_business_media( + account_id: str, media_id: str, location_id: str = "" + ) -> str: + """Delete photo + + Args: + account_id: (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + media_id: The media item ID to delete (required)""" + client = _get_client() + try: + response = client.accounts.delete_google_business_media( + account_id=account_id, location_id=location_id, media_id=media_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_google_business_attributes( + account_id: str, location_id: str = "" + ) -> str: + """Get attributes + + Args: + account_id: (required) + location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.""" + client = _get_client() + try: + response = client.accounts.get_google_business_attributes( + account_id=account_id, location_id=location_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_update_google_business_attributes( + account_id: str, attributes: str, attribute_mask: str, location_id: str = "" + ) -> str: + """Update attributes + + Args: + account_id: (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + attributes: (required) + attribute_mask: Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout') (required)""" + client = _get_client() + try: + response = client.accounts.update_google_business_attributes( + account_id=account_id, + location_id=location_id, + attributes=attributes, + attributeMask=attribute_mask, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_list_google_business_place_actions( + account_id: str, + location_id: str = "", + page_size: int = 100, + page_token: str = "", + ) -> str: + """List action links + + Args: + account_id: (required) + location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + page_size + page_token""" + client = _get_client() + try: + response = client.accounts.list_google_business_place_actions( + account_id=account_id, + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_create_google_business_place_action( + account_id: str, uri: str, place_action_type: str, location_id: str = "" + ) -> str: + """Create action link + + Args: + account_id: (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + uri: The action URL (required) + place_action_type: Type of action (required)""" + client = _get_client() + try: + response = client.accounts.create_google_business_place_action( + account_id=account_id, + location_id=location_id, + uri=uri, + placeActionType=place_action_type, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_delete_google_business_place_action( + account_id: str, name: str, location_id: str = "" + ) -> str: + """Delete action link + + Args: + account_id: (required) + location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. + name: The resource name of the place action link (e.g. locations/123/placeActionLinks/456) (required)""" + client = _get_client() + try: + response = client.accounts.delete_google_business_place_action( + account_id=account_id, location_id=location_id, name=name + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def accounts_get_linked_in_mentions( + account_id: str, url: str, display_name: str = "" + ) -> str: + """Resolve LinkedIn mention + + Args: + account_id: The LinkedIn account ID (required) + url: LinkedIn profile URL, company URL, or vanity name. (required) + display_name: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions.""" + client = _get_client() + try: + response = client.accounts.get_linked_in_mentions( + account_id=account_id, url=url, display_name=display_name + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # ANALYTICS + + @mcp.tool() + def analytics_get_analytics( + post_id: str = "", + platform: str = "", + profile_id: str = "", + source: str = "all", + from_date: str = "", + to_date: str = "", + limit: int = 50, + page: int = 1, + sort_by: str = "date", + order: str = "desc", + ) -> str: + """Get post analytics + + Args: + post_id: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics. + platform: Filter by platform (default "all") + profile_id: Filter by profile ID (default "all") + source: Filter by post source: late (posted via Zernio API), external (synced from platform), all (default) + from_date: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days. + to_date: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted. + limit: Page size (default 50) + page: Page number (default 1) + sort_by: Sort by date, engagement, or a specific metric + order: Sort order""" + client = _get_client() + try: + response = client.analytics.get_analytics( + post_id=post_id, + platform=platform, + profile_id=profile_id, + source=source, + from_date=from_date, + to_date=to_date, + limit=limit, + page=page, + sort_by=sort_by, + order=order, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_you_tube_daily_views( + video_id: str, account_id: str, start_date: str = "", end_date: str = "" + ) -> str: + """Get YouTube daily views + + Args: + video_id: The YouTube video ID (e.g., "dQw4w9WgXcQ") (required) + account_id: The Zernio account ID for the YouTube account (required) + start_date: Start date (YYYY-MM-DD). Defaults to 30 days ago. + end_date: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency).""" + client = _get_client() + try: + response = client.analytics.get_you_tube_daily_views( + video_id=video_id, + account_id=account_id, + start_date=start_date, + end_date=end_date, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_daily_metrics( + platform: str = "", + profile_id: str = "", + from_date: str = "", + to_date: str = "", + source: str = "all", + ) -> str: + """Get daily aggregated metrics + + Args: + platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + profile_id: Filter by profile ID. Omit for all profiles. + from_date: Inclusive start date (ISO 8601). Defaults to 180 days ago. + to_date: Inclusive end date (ISO 8601). Defaults to now. + source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" + client = _get_client() + try: + response = client.analytics.get_daily_metrics( + platform=platform, + profile_id=profile_id, + from_date=from_date, + to_date=to_date, + source=source, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_best_time_to_post( + platform: str = "", profile_id: str = "", source: str = "all" + ) -> str: + """Get best times to post + + Args: + platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + profile_id: Filter by profile ID. Omit for all profiles. + source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" + client = _get_client() + try: + response = client.analytics.get_best_time_to_post( + platform=platform, profile_id=profile_id, source=source + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_content_decay( + platform: str = "", profile_id: str = "", source: str = "all" + ) -> str: + """Get content performance decay + + Args: + platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + profile_id: Filter by profile ID. Omit for all profiles. + source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" + client = _get_client() + try: + response = client.analytics.get_content_decay( + platform=platform, profile_id=profile_id, source=source + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_posting_frequency( + platform: str = "", profile_id: str = "", source: str = "all" + ) -> str: + """Get posting frequency vs engagement + + Args: + platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. + profile_id: Filter by profile ID. Omit for all profiles. + source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" + client = _get_client() + try: + response = client.analytics.get_posting_frequency( + platform=platform, profile_id=profile_id, source=source + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_post_timeline( + post_id: str, from_date: str = "", to_date: str = "" + ) -> str: + """Get post analytics timeline + + Args: + post_id: The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID. + (required) + from_date: Start of date range (ISO 8601). Defaults to 90 days ago. + to_date: End of date range (ISO 8601). Defaults to now.""" + client = _get_client() + try: + response = client.analytics.get_post_timeline( + post_id=post_id, from_date=from_date, to_date=to_date + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_linked_in_aggregate_analytics( + account_id: str, + aggregation: str = "TOTAL", + start_date: str = "", + end_date: str = "", + metrics: str = "", + ) -> str: + """Get LinkedIn aggregate stats + + Args: + account_id: The ID of the LinkedIn personal account (required) + aggregation: TOTAL (default, lifetime totals) or DAILY (time series). MEMBERS_REACHED not available with DAILY. + start_date: Start date (YYYY-MM-DD). If omitted, returns lifetime analytics. + end_date: End date (YYYY-MM-DD, exclusive). Defaults to today if omitted. + metrics: Comma-separated metrics: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE. Omit for all.""" + client = _get_client() + try: + response = client.analytics.get_linked_in_aggregate_analytics( + account_id=account_id, + aggregation=aggregation, + start_date=start_date, + end_date=end_date, + metrics=metrics, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_linked_in_post_analytics(account_id: str, urn: str) -> str: + """Get LinkedIn post stats + + Args: + account_id: The ID of the LinkedIn account (required) + urn: The LinkedIn post URN (required)""" + client = _get_client() + try: + response = client.analytics.get_linked_in_post_analytics( + account_id=account_id, urn=urn + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def analytics_get_linked_in_post_reactions( + account_id: str, urn: str, limit: int = 25, cursor: str = "" + ) -> str: + """Get LinkedIn post reactions + + Args: + account_id: The ID of the LinkedIn organization account (required) + urn: The LinkedIn post URN (required) + limit: Maximum number of reactions to return per page + cursor: Offset-based pagination start index""" + client = _get_client() + try: + response = client.analytics.get_linked_in_post_reactions( + account_id=account_id, urn=urn, limit=limit, cursor=cursor + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # API_KEYS + + @mcp.tool() + def api_keys_list_api_keys() -> str: + """List keys""" + client = _get_client() + try: + response = client.api_keys.list_api_keys() + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def api_keys_create_api_key( + name: str, + expires_in: int = 0, + scope: str = "full", + profile_ids: str = "", + permission: str = "read-write", + ) -> str: + """Create key + + Args: + name: (required) + expires_in: Days until expiry + scope: 'full' grants access to all profiles (default), 'profiles' restricts to specific profiles + profile_ids: Profile IDs this key can access. Required when scope is 'profiles'. + permission: 'read-write' allows all operations (default), 'read' restricts to GET requests only""" + client = _get_client() + try: + response = client.api_keys.create_api_key( + name=name, + expiresIn=expires_in, + scope=scope, + profileIds=profile_ids, + permission=permission, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def api_keys_delete_api_key(key_id: str) -> str: + """Delete key + + Args: + key_id: (required)""" + client = _get_client() + try: + response = client.api_keys.delete_api_key(key_id=key_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # COMMENTS + + @mcp.tool() + def comments_list_inbox_comments( + profile_id: str = "", + platform: str = "", + min_comments: int = 0, + since: str = "", + sort_by: str = "date", + sort_order: str = "desc", + limit: int = 50, + cursor: str = "", + account_id: str = "", + ) -> str: + """List commented posts + + Args: + profile_id: Filter by profile ID + platform: Filter by platform + min_comments: Minimum comment count + since: Posts created after this date + sort_by: Sort field + sort_order: Sort order + limit + cursor + account_id: Filter by specific social account ID""" + client = _get_client() + try: + response = client.comments.list_inbox_comments( + profile_id=profile_id, + platform=platform, + min_comments=min_comments, + since=since, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_get_inbox_post_comments( + post_id: str, + account_id: str, + subreddit: str = "", + limit: int = 25, + cursor: str = "", + comment_id: str = "", + ) -> str: + """Get post comments + + Args: + post_id: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID. (required) + account_id: (required) + subreddit: (Reddit only) Subreddit name + limit: Maximum number of comments to return + cursor: Pagination cursor + comment_id: (Reddit only) Get replies to a specific comment""" + client = _get_client() + try: + response = client.comments.get_inbox_post_comments( + post_id=post_id, + account_id=account_id, + subreddit=subreddit, + limit=limit, + cursor=cursor, + comment_id=comment_id, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_reply_to_inbox_post( + post_id: str, + account_id: str, + message: str, + comment_id: str = "", + parent_cid: str = "", + root_uri: str = "", + root_cid: str = "", + ) -> str: + """Reply to comment + + Args: + post_id: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. (required) + account_id: (required) + message: (required) + comment_id: Reply to specific comment (optional) + parent_cid: (Bluesky only) Parent content identifier + root_uri: (Bluesky only) Root post URI + root_cid: (Bluesky only) Root post CID""" + client = _get_client() + try: + response = client.comments.reply_to_inbox_post( + post_id=post_id, + accountId=account_id, + message=message, + commentId=comment_id, + parentCid=parent_cid, + rootUri=root_uri, + rootCid=root_cid, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_delete_inbox_comment( + post_id: str, account_id: str, comment_id: str + ) -> str: + """Delete comment + + Args: + post_id: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. (required) + account_id: (required) + comment_id: (required)""" + client = _get_client() + try: + response = client.comments.delete_inbox_comment( + post_id=post_id, account_id=account_id, comment_id=comment_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_hide_inbox_comment( + post_id: str, comment_id: str, account_id: str + ) -> str: + """Hide comment + + Args: + post_id: (required) + comment_id: (required) + account_id: The social account ID (required)""" + client = _get_client() + try: + response = client.comments.hide_inbox_comment( + post_id=post_id, comment_id=comment_id, accountId=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_unhide_inbox_comment( + post_id: str, comment_id: str, account_id: str + ) -> str: + """Unhide comment + + Args: + post_id: (required) + comment_id: (required) + account_id: (required)""" + client = _get_client() + try: + response = client.comments.unhide_inbox_comment( + post_id=post_id, comment_id=comment_id, account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_like_inbox_comment( + post_id: str, comment_id: str, account_id: str, cid: str = "" + ) -> str: + """Like comment + + Args: + post_id: (required) + comment_id: (required) + account_id: The social account ID (required) + cid: (Bluesky only) Content identifier for the comment""" + client = _get_client() + try: + response = client.comments.like_inbox_comment( + post_id=post_id, comment_id=comment_id, accountId=account_id, cid=cid + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_unlike_inbox_comment( + post_id: str, comment_id: str, account_id: str, like_uri: str = "" + ) -> str: + """Unlike comment + + Args: + post_id: (required) + comment_id: (required) + account_id: (required) + like_uri: (Bluesky only) The like URI returned when liking""" + client = _get_client() + try: + response = client.comments.unlike_inbox_comment( + post_id=post_id, + comment_id=comment_id, + account_id=account_id, + like_uri=like_uri, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def comments_send_private_reply_to_comment( + post_id: str, comment_id: str, account_id: str, message: str + ) -> str: + """Send private reply + + Args: + post_id: The media/post ID (Instagram media ID or Facebook post ID) (required) + comment_id: The comment ID to send a private reply to (required) + account_id: The social account ID (Instagram or Facebook) (required) + message: The message text to send as a private DM (required)""" + client = _get_client() + try: + response = client.comments.send_private_reply_to_comment( + post_id=post_id, + comment_id=comment_id, + accountId=account_id, + message=message, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # CONNECT + + @mcp.tool() + def connect_get_connect_url( + platform: str, profile_id: str, redirect_url: str = "", headless: bool = False + ) -> str: + """Get OAuth connect URL + + Args: + platform: Social media platform to connect (required) + profile_id: Your Zernio profile ID (get from /v1/profiles) (required) + redirect_url: Your custom redirect URL after connection completes. Standard mode appends ?connected={platform}&profileId=X&accountId=Y&username=Z. Headless mode appends OAuth data params for platforms requiring selection (e.g. LinkedIn orgs, Facebook pages). If no selection is needed, the account is created directly and the redirect includes accountId. + headless: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience.""" + client = _get_client() + try: + response = client.connect.get_connect_url( + platform=platform, + profile_id=profile_id, + redirect_url=redirect_url, + headless=headless, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_handle_o_auth_callback( + platform: str, code: str, state: str, profile_id: str + ) -> str: + """Complete OAuth callback + + Args: + platform: (required) + code: (required) + state: (required) + profile_id: (required)""" + client = _get_client() + try: + response = client.connect.handle_o_auth_callback( + platform=platform, code=code, state=state, profileId=profile_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_list_facebook_pages(profile_id: str, temp_token: str) -> str: + """List Facebook pages + + Args: + profile_id: Profile ID from your connection flow (required) + temp_token: Temporary Facebook access token from the OAuth callback redirect (required)""" + client = _get_client() + try: + response = client.connect.list_facebook_pages( + profile_id=profile_id, temp_token=temp_token + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_select_facebook_page( + profile_id: str, + page_id: str, + temp_token: str, + user_profile: str = "", + redirect_url: str = "", + ) -> str: + """Select Facebook page + + Args: + profile_id: Profile ID from your connection flow (required) + page_id: The Facebook Page ID selected by the user (required) + temp_token: Temporary Facebook access token from OAuth (required) + user_profile: Decoded user profile object from the OAuth callback + redirect_url: Optional custom redirect URL to return to after selection""" + client = _get_client() + try: + response = client.connect.select_facebook_page( + profileId=profile_id, + pageId=page_id, + tempToken=temp_token, + userProfile=user_profile, + redirect_url=redirect_url, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_list_google_business_locations(profile_id: str, temp_token: str) -> str: + """List GBP locations + + Args: + profile_id: Profile ID from your connection flow (required) + temp_token: Temporary Google access token from the OAuth callback redirect (required)""" + client = _get_client() + try: + response = client.connect.list_google_business_locations( + profile_id=profile_id, temp_token=temp_token + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_select_google_business_location( + profile_id: str, + location_id: str, + temp_token: str, + user_profile: str = "", + redirect_url: str = "", + ) -> str: + """Select GBP location + + Args: + profile_id: Profile ID from your connection flow (required) + location_id: The Google Business location ID selected by the user (required) + temp_token: Temporary Google access token from OAuth (required) + user_profile: Decoded user profile from the OAuth callback. Contains the refresh token. Always include this field. + redirect_url: Optional custom redirect URL to return to after selection""" + client = _get_client() + try: + response = client.connect.select_google_business_location( + profileId=profile_id, + locationId=location_id, + tempToken=temp_token, + userProfile=user_profile, + redirect_url=redirect_url, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_pending_o_auth_data(token: str) -> str: + """Get pending OAuth data + + Args: + token: The pending data token from the OAuth redirect URL (pendingDataToken parameter) (required)""" + client = _get_client() + try: + response = client.connect.get_pending_o_auth_data(token=token) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_list_linked_in_organizations(temp_token: str, org_ids: str) -> str: + """List LinkedIn orgs + + Args: + temp_token: The temporary LinkedIn access token from the OAuth redirect (required) + org_ids: Comma-separated list of organization IDs to fetch details for (max 100) (required)""" + client = _get_client() + try: + response = client.connect.list_linked_in_organizations( + temp_token=temp_token, org_ids=org_ids + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_select_linked_in_organization( + profile_id: str, + temp_token: str, + user_profile: str, + account_type: str, + selected_organization: str = "", + redirect_url: str = "", + ) -> str: + """Select LinkedIn org + + Args: + profile_id: (required) + temp_token: (required) + user_profile: (required) + account_type: (required) + selected_organization + redirect_url""" + client = _get_client() + try: + response = client.connect.select_linked_in_organization( + profileId=profile_id, + tempToken=temp_token, + userProfile=user_profile, + accountType=account_type, + selectedOrganization=selected_organization, + redirect_url=redirect_url, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_list_pinterest_boards_for_selection( + profile_id: str, temp_token: str + ) -> str: + """List Pinterest boards + + Args: + profile_id: Your Zernio profile ID (required) + temp_token: Temporary Pinterest access token from the OAuth callback redirect (required)""" + client = _get_client() + try: + response = client.connect.list_pinterest_boards_for_selection( + profile_id=profile_id, temp_token=temp_token + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_select_pinterest_board( + profile_id: str, + board_id: str, + temp_token: str, + board_name: str = "", + user_profile: str = "", + refresh_token: str = "", + expires_in: int = 0, + redirect_url: str = "", + ) -> str: + """Select Pinterest board + + Args: + profile_id: Your Zernio profile ID (required) + board_id: The Pinterest Board ID selected by the user (required) + board_name: The board name (for display purposes) + temp_token: Temporary Pinterest access token from OAuth (required) + user_profile: User profile data from OAuth redirect + refresh_token: Pinterest refresh token (if available) + expires_in: Token expiration time in seconds + redirect_url: Custom redirect URL after connection completes""" + client = _get_client() + try: + response = client.connect.select_pinterest_board( + profileId=profile_id, + boardId=board_id, + boardName=board_name, + tempToken=temp_token, + userProfile=user_profile, + refreshToken=refresh_token, + expiresIn=expires_in, + redirect_url=redirect_url, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_list_snapchat_profiles(profile_id: str, temp_token: str) -> str: + """List Snapchat profiles + + Args: + profile_id: Your Zernio profile ID (required) + temp_token: Temporary Snapchat access token from the OAuth callback redirect (required)""" + client = _get_client() + try: + response = client.connect.list_snapchat_profiles( + profile_id=profile_id, temp_token=temp_token + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_select_snapchat_profile( + profile_id: str, + selected_public_profile: str, + temp_token: str, + user_profile: str, + refresh_token: str = "", + expires_in: int = 0, + redirect_url: str = "", + ) -> str: + """Select Snapchat profile + + Args: + profile_id: Your Zernio profile ID (required) + selected_public_profile: The selected Snapchat Public Profile (required) + temp_token: Temporary Snapchat access token from OAuth (required) + user_profile: User profile data from OAuth redirect (required) + refresh_token: Snapchat refresh token (if available) + expires_in: Token expiration time in seconds + redirect_url: Custom redirect URL after connection completes""" + client = _get_client() + try: + response = client.connect.select_snapchat_profile( + profileId=profile_id, + selectedPublicProfile=selected_public_profile, + tempToken=temp_token, + userProfile=user_profile, + refreshToken=refresh_token, + expiresIn=expires_in, + redirect_url=redirect_url, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_bluesky_credentials( + identifier: str, app_password: str, state: str, redirect_uri: str = "" + ) -> str: + """Connect Bluesky account + + Args: + identifier: Your Bluesky handle (e.g. user.bsky.social) or email address (required) + app_password: App password generated from Bluesky Settings > App Passwords (required) + state: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles. (required) + redirect_uri: Optional URL to redirect to after successful connection""" + client = _get_client() + try: + response = client.connect.connect_bluesky_credentials( + identifier=identifier, + appPassword=app_password, + state=state, + redirectUri=redirect_uri, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_whats_app_credentials( + profile_id: str, access_token: str, waba_id: str, phone_number_id: str + ) -> str: + """Connect WhatsApp via credentials + + Args: + profile_id: Your Late profile ID (required) + access_token: Permanent System User access token from Meta Business Suite (required) + waba_id: WhatsApp Business Account ID from Meta (required) + phone_number_id: Phone Number ID from Meta WhatsApp Manager (required)""" + client = _get_client() + try: + response = client.connect.connect_whats_app_credentials( + profileId=profile_id, + accessToken=access_token, + wabaId=waba_id, + phoneNumberId=phone_number_id, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_telegram_connect_status(profile_id: str) -> str: + """Generate Telegram code + + Args: + profile_id: The profile ID to connect the Telegram account to (required)""" + client = _get_client() + try: + response = client.connect.get_telegram_connect_status(profile_id=profile_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_initiate_telegram_connect(chat_id: str, profile_id: str) -> str: + """Connect Telegram directly + + Args: + chat_id: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel"). (required) + profile_id: The profile ID to connect the account to (required)""" + client = _get_client() + try: + response = client.connect.initiate_telegram_connect( + chatId=chat_id, profileId=profile_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_complete_telegram_connect(code: str) -> str: + """Check Telegram status + + Args: + code: The access code to check status for (required)""" + client = _get_client() + try: + response = client.connect.complete_telegram_connect(code=code) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_facebook_pages(account_id: str) -> str: + """List Facebook pages + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.connect.get_facebook_pages(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_update_facebook_page(account_id: str, selected_page_id: str) -> str: + """Update Facebook page + + Args: + account_id: (required) + selected_page_id: (required)""" + client = _get_client() + try: + response = client.connect.update_facebook_page( + account_id=account_id, selectedPageId=selected_page_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_linked_in_organizations(account_id: str) -> str: + """List LinkedIn orgs + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.connect.get_linked_in_organizations(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_update_linked_in_organization( + account_id: str, account_type: str, selected_organization: str = "" + ) -> str: + """Switch LinkedIn account type + + Args: + account_id: (required) + account_type: (required) + selected_organization""" + client = _get_client() + try: + response = client.connect.update_linked_in_organization( + account_id=account_id, + accountType=account_type, + selectedOrganization=selected_organization, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_pinterest_boards(account_id: str) -> str: + """List Pinterest boards + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.connect.get_pinterest_boards(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_update_pinterest_boards( + account_id: str, default_board_id: str, default_board_name: str = "" + ) -> str: + """Set default Pinterest board + + Args: + account_id: (required) + default_board_id: (required) + default_board_name""" + client = _get_client() + try: + response = client.connect.update_pinterest_boards( + account_id=account_id, + defaultBoardId=default_board_id, + defaultBoardName=default_board_name, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_gmb_locations(account_id: str) -> str: + """List GBP locations + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.connect.get_gmb_locations(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_update_gmb_location(account_id: str, selected_location_id: str) -> str: + """Update GBP location + + Args: + account_id: (required) + selected_location_id: (required)""" + client = _get_client() + try: + response = client.connect.update_gmb_location( + account_id=account_id, selectedLocationId=selected_location_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_reddit_subreddits(account_id: str) -> str: + """List Reddit subreddits + + Args: + account_id: (required)""" + client = _get_client() + try: + response = client.connect.get_reddit_subreddits(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_update_reddit_subreddits( + account_id: str, default_subreddit: str + ) -> str: + """Set default subreddit + + Args: + account_id: (required) + default_subreddit: (required)""" + client = _get_client() + try: + response = client.connect.update_reddit_subreddits( + account_id=account_id, defaultSubreddit=default_subreddit + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def connect_get_reddit_flairs(account_id: str, subreddit: str) -> str: + """List subreddit flairs + + Args: + account_id: (required) + subreddit: Subreddit name (without "r/" prefix) to fetch flairs for (required)""" + client = _get_client() + try: + response = client.connect.get_reddit_flairs( + account_id=account_id, subreddit=subreddit + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # INVITES + + @mcp.tool() + def invites_create_invite_token(scope: str, profile_ids: str = "") -> str: + """Create invite token + + Args: + scope: 'all' grants access to all profiles, 'profiles' restricts to specific profiles (required) + profile_ids: Required if scope is 'profiles'. Array of profile IDs to grant access to.""" + client = _get_client() + try: + response = client.invites.create_invite_token( + scope=scope, profileIds=profile_ids + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # LOGS + + @mcp.tool() + def logs_list_posts_logs( + status: str = "", + platform: str = "", + action: str = "", + days: int = 7, + limit: int = 50, + skip: int = 0, + search: str = "", + ) -> str: + """List publishing logs + + Args: + status: Filter by log status + platform: Filter by platform + action: Filter by action type + days: Number of days to look back (max 7) + limit: Maximum number of logs to return (max 100) + skip: Number of logs to skip (for pagination) + search: Search through log entries by text content.""" + client = _get_client() + try: + response = client.logs.list_posts_logs( + status=status, + platform=platform, + action=action, + days=days, + limit=limit, + skip=skip, + search=search, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def logs_list_connection_logs( + platform: str = "", + event_type: str = "", + status: str = "", + days: int = 7, + limit: int = 50, + skip: int = 0, + ) -> str: + """List connection logs + + Args: + platform: Filter by platform + event_type: Filter by event type + status: Filter by status (shorthand for event types) + days: Number of days to look back (max 7) + limit: Maximum number of logs to return (max 100) + skip: Number of logs to skip (for pagination)""" + client = _get_client() + try: + response = client.logs.list_connection_logs( + platform=platform, + event_type=event_type, + status=status, + days=days, + limit=limit, + skip=skip, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def logs_get_post_logs(post_id: str, limit: int = 50) -> str: + """Get post logs + + Args: + post_id: The post ID (required) + limit: Maximum number of logs to return (max 100)""" + client = _get_client() + try: + response = client.logs.get_post_logs(post_id=post_id, limit=limit) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # MEDIA + + @mcp.tool() + def media_get_media_presigned_url( + filename: str, content_type: str, size: int = 0 + ) -> str: + """Get presigned upload URL + + Args: + filename: Name of the file to upload (required) + content_type: MIME type of the file (required) + size: Optional file size in bytes for pre-validation (max 5GB)""" + client = _get_client() + try: + response = client.media.get_media_presigned_url( + filename=filename, contentType=content_type, size=size + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # MESSAGES + + @mcp.tool() + def messages_list_inbox_conversations( + profile_id: str = "", + platform: str = "", + status: str = "", + sort_order: str = "desc", + limit: int = 50, + cursor: str = "", + account_id: str = "", + ) -> str: + """List conversations + + Args: + profile_id: Filter by profile ID + platform: Filter by platform + status: Filter by conversation status + sort_order: Sort order by updated time + limit: Maximum number of conversations to return + cursor: Pagination cursor for next page + account_id: Filter by specific social account ID""" + client = _get_client() + try: + response = client.messages.list_inbox_conversations( + profile_id=profile_id, + platform=platform, + status=status, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def messages_get_inbox_conversation(conversation_id: str, account_id: str) -> str: + """Get conversation + + Args: + conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) + account_id: The social account ID (required)""" + client = _get_client() + try: + response = client.messages.get_inbox_conversation( + conversation_id=conversation_id, account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def messages_update_inbox_conversation( + conversation_id: str, account_id: str, status: str + ) -> str: + """Update conversation status + + Args: + conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) + account_id: Social account ID (required) + status: (required)""" + client = _get_client() + try: + response = client.messages.update_inbox_conversation( + conversation_id=conversation_id, accountId=account_id, status=status + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def messages_get_inbox_conversation_messages( + conversation_id: str, account_id: str + ) -> str: + """List messages + + Args: + conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) + account_id: Social account ID (required)""" + client = _get_client() + try: + response = client.messages.get_inbox_conversation_messages( + conversation_id=conversation_id, account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def messages_send_inbox_message( + conversation_id: str, + account_id: str, + message: str = "", + quick_replies: str = "", + buttons: str = "", + template: str = "", + reply_markup: str = "", + messaging_type: str = "", + message_tag: str = "", + reply_to: str = "", + ) -> str: + """Send message + + Args: + conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) + account_id: Social account ID (required) + message: Message text + quick_replies: Quick reply buttons. Mutually exclusive with buttons. Max 13 items. + buttons: Action buttons. Mutually exclusive with quickReplies. Max 3 items. + template: Generic template for carousels (Instagram/Facebook only, ignored on Telegram). + reply_markup: Telegram-native keyboard markup. Ignored on other platforms. + messaging_type: Facebook messaging type. Required when using messageTag. + message_tag: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT. + reply_to: Platform message ID to reply to (Telegram only).""" + client = _get_client() + try: + response = client.messages.send_inbox_message( + conversation_id=conversation_id, + accountId=account_id, + message=message, + quickReplies=quick_replies, + buttons=buttons, + template=template, + replyMarkup=reply_markup, + messagingType=messaging_type, + messageTag=message_tag, + replyTo=reply_to, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def messages_edit_inbox_message( + conversation_id: str, + message_id: str, + account_id: str, + text: str = "", + reply_markup: str = "", + ) -> str: + """Edit message + + Args: + conversation_id: The conversation ID (required) + message_id: The Telegram message ID to edit (required) + account_id: Social account ID (required) + text: New message text + reply_markup: New inline keyboard markup""" + client = _get_client() + try: + response = client.messages.edit_inbox_message( + conversation_id=conversation_id, + message_id=message_id, + accountId=account_id, + text=text, + replyMarkup=reply_markup, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # POSTS + + @mcp.tool() + def posts_list_posts( + page: int = 1, + limit: int = 10, + status: str = "", + platform: str = "", + profile_id: str = "", + created_by: str = "", + date_from: str = "", + date_to: str = "", + include_hidden: bool = False, + search: str = "", + sort_by: str = "scheduled-desc", + ) -> str: + """List posts + + Args: + page: Page number + limit: Results per page + status + platform + profile_id + created_by + date_from + date_to + include_hidden + search: Search posts by text content. + sort_by: Sort order for results.""" + client = _get_client() + try: + response = client.posts.list_posts( + page=page, + limit=limit, + status=status, + platform=platform, + profile_id=profile_id, + created_by=created_by, + date_from=date_from, + date_to=date_to, + include_hidden=include_hidden, + search=search, + sort_by=sort_by, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def posts_get_post(post_id: str) -> str: + """Get post + + Args: + post_id: (required)""" + client = _get_client() + try: + response = client.posts.get_post(post_id=post_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def posts_update_post( + post_id: str, + content: str = "", + scheduled_for: str = "", + tiktok_settings: str = "", + recycling: str = "", + ) -> str: + """Update post + + Args: + post_id: (required) + content + scheduled_for + tiktok_settings: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. + recycling""" + client = _get_client() + try: + response = client.posts.update_post( + post_id=post_id, + content=content, + scheduledFor=scheduled_for, + tiktokSettings=tiktok_settings, + recycling=recycling, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def posts_delete_post(post_id: str) -> str: + """Delete post + + Args: + post_id: (required)""" + client = _get_client() + try: + response = client.posts.delete_post(post_id=post_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def posts_bulk_upload_posts(dry_run: bool = False) -> str: + """Bulk upload from CSV + + Args: + dry_run""" + client = _get_client() + try: + response = client.posts.bulk_upload_posts(dry_run=dry_run) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def posts_unpublish_post(post_id: str, platform: str) -> str: + """Unpublish post + + Args: + post_id: (required) + platform: The platform to delete the post from (required)""" + client = _get_client() + try: + response = client.posts.unpublish_post(post_id=post_id, platform=platform) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # PROFILES + + @mcp.tool() + def profiles_list_profiles(include_over_limit: bool = False) -> str: + """List profiles + + Args: + include_over_limit: When true, includes over-limit profiles (marked with isOverLimit: true).""" + client = _get_client() + try: + response = client.profiles.list_profiles( + include_over_limit=include_over_limit + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def profiles_create_profile( + name: str, description: str = "", color: str = "" + ) -> str: + """Create profile + + Args: + name: (required) + description + color""" + client = _get_client() + try: + response = client.profiles.create_profile( + name=name, description=description, color=color + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def profiles_get_profile(profile_id: str) -> str: + """Get profile + + Args: + profile_id: (required)""" + client = _get_client() + try: + response = client.profiles.get_profile(profile_id=profile_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def profiles_update_profile( + profile_id: str, + name: str = "", + description: str = "", + color: str = "", + is_default: bool = False, + ) -> str: + """Update profile + + Args: + profile_id: (required) + name + description + color + is_default""" + client = _get_client() + try: + response = client.profiles.update_profile( + profile_id=profile_id, + name=name, + description=description, + color=color, + isDefault=is_default, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def profiles_delete_profile(profile_id: str) -> str: + """Delete profile + + Args: + profile_id: (required)""" + client = _get_client() + try: + response = client.profiles.delete_profile(profile_id=profile_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # QUEUE + + @mcp.tool() + def queue_list_queue_slots( + profile_id: str, queue_id: str = "", all: str = "" + ) -> str: + """List schedules + + Args: + profile_id: Profile ID to get queues for (required) + queue_id: Specific queue ID to retrieve (optional) + all: Set to 'true' to list all queues for the profile""" + client = _get_client() + try: + response = client.queue.list_queue_slots( + profile_id=profile_id, queue_id=queue_id, all=all + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def queue_create_queue_slot( + profile_id: str, name: str, timezone: str, slots: str, active: bool = True + ) -> str: + """Create schedule + + Args: + profile_id: Profile ID (required) + name: Queue name (e.g., Evening Posts) (required) + timezone: IANA timezone (required) + slots: (required) + active""" + client = _get_client() + try: + response = client.queue.create_queue_slot( + profileId=profile_id, + name=name, + timezone=timezone, + slots=slots, + active=active, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def queue_update_queue_slot( + profile_id: str, + timezone: str, + slots: str, + queue_id: str = "", + name: str = "", + active: bool = True, + set_as_default: bool = False, + reshuffle_existing: bool = False, + ) -> str: + """Update schedule + + Args: + profile_id: (required) + queue_id: Queue ID to update (optional) + name: Queue name + timezone: (required) + slots: (required) + active + set_as_default: Make this queue the default + reshuffle_existing: Whether to reschedule existing queued posts to match new slots""" + client = _get_client() + try: + response = client.queue.update_queue_slot( + profileId=profile_id, + queueId=queue_id, + name=name, + timezone=timezone, + slots=slots, + active=active, + setAsDefault=set_as_default, + reshuffleExisting=reshuffle_existing, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def queue_delete_queue_slot(profile_id: str, queue_id: str) -> str: + """Delete schedule + + Args: + profile_id: (required) + queue_id: Queue ID to delete (required)""" + client = _get_client() + try: + response = client.queue.delete_queue_slot( + profile_id=profile_id, queue_id=queue_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def queue_preview_queue( + profile_id: str, queue_id: str = "", count: int = 20 + ) -> str: + """Preview upcoming slots + + Args: + profile_id: (required) + queue_id: Filter by specific queue ID. Omit to use the default queue. + count""" + client = _get_client() + try: + response = client.queue.preview_queue( + profile_id=profile_id, queue_id=queue_id, count=count + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def queue_get_next_queue_slot(profile_id: str, queue_id: str = "") -> str: + """Get next available slot + + Args: + profile_id: (required) + queue_id: Specific queue ID (optional, defaults to profile's default queue)""" + client = _get_client() + try: + response = client.queue.get_next_queue_slot( + profile_id=profile_id, queue_id=queue_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # REDDIT + + @mcp.tool() + def reddit_search_reddit( + account_id: str, + q: str, + subreddit: str = "", + restrict_sr: str = "", + sort: str = "new", + limit: int = 25, + after: str = "", + ) -> str: + """Search posts + + Args: + account_id: (required) + subreddit + q: (required) + restrict_sr + sort + limit + after""" + client = _get_client() + try: + response = client.reddit.search_reddit( + account_id=account_id, + subreddit=subreddit, + q=q, + restrict_sr=restrict_sr, + sort=sort, + limit=limit, + after=after, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def reddit_get_reddit_feed( + account_id: str, + subreddit: str = "", + sort: str = "hot", + limit: int = 25, + after: str = "", + t: str = "", + ) -> str: + """Get subreddit feed + + Args: + account_id: (required) + subreddit + sort + limit + after + t""" + client = _get_client() + try: + response = client.reddit.get_reddit_feed( + account_id=account_id, + subreddit=subreddit, + sort=sort, + limit=limit, + after=after, + t=t, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # REVIEWS + + @mcp.tool() + def reviews_list_inbox_reviews( + profile_id: str = "", + platform: str = "", + min_rating: int = 0, + max_rating: int = 0, + has_reply: bool = False, + sort_by: str = "date", + sort_order: str = "desc", + limit: int = 25, + cursor: str = "", + account_id: str = "", + ) -> str: + """List reviews + + Args: + profile_id + platform + min_rating + max_rating + has_reply: Filter by reply status + sort_by + sort_order + limit + cursor + account_id: Filter by specific social account ID""" + client = _get_client() + try: + response = client.reviews.list_inbox_reviews( + profile_id=profile_id, + platform=platform, + min_rating=min_rating, + max_rating=max_rating, + has_reply=has_reply, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def reviews_reply_to_inbox_review( + review_id: str, account_id: str, message: str + ) -> str: + """Reply to review + + Args: + review_id: Review ID (URL-encoded for Google Business) (required) + account_id: (required) + message: (required)""" + client = _get_client() + try: + response = client.reviews.reply_to_inbox_review( + review_id=review_id, accountId=account_id, message=message + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def reviews_delete_inbox_review_reply(review_id: str, account_id: str) -> str: + """Delete review reply + + Args: + review_id: (required) + account_id: (required)""" + client = _get_client() + try: + response = client.reviews.delete_inbox_review_reply( + review_id=review_id, accountId=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # TOOLS + + @mcp.tool() + def tools_download_you_tube_video( + url: str, + action: str = "download", + format: str = "video", + quality: str = "hd", + format_id: str = "", + ) -> str: + """Download YouTube video + + Args: + url: YouTube video URL or video ID (required) + action: Action to perform: 'download' returns download URL, 'formats' lists available formats + format: Desired format (when action=download) + quality: Desired quality (when action=download) + format_id: Specific format ID from formats list""" + client = _get_client() + try: + response = client.tools.download_you_tube_video( + url=url, + action=action, + format=format, + quality=quality, + format_id=format_id, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_get_you_tube_transcript(url: str, lang: str = "en") -> str: + """Get YouTube transcript + + Args: + url: YouTube video URL or video ID (required) + lang: Language code for transcript""" + client = _get_client() + try: + response = client.tools.get_you_tube_transcript(url=url, lang=lang) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_download_instagram_media(url: str) -> str: + """Download Instagram media + + Args: + url: Instagram reel or post URL (required)""" + client = _get_client() + try: + response = client.tools.download_instagram_media(url=url) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_check_instagram_hashtags(hashtags: str) -> str: + """Check IG hashtag bans + + Args: + hashtags: (required)""" + client = _get_client() + try: + response = client.tools.check_instagram_hashtags(hashtags=hashtags) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_download_tik_tok_video( + url: str, action: str = "download", format_id: str = "" + ) -> str: + """Download TikTok video + + Args: + url: TikTok video URL or ID (required) + action: 'formats' to list available formats + format_id: Specific format ID (0 = no watermark, etc.)""" + client = _get_client() + try: + response = client.tools.download_tik_tok_video( + url=url, action=action, format_id=format_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_download_twitter_media( + url: str, action: str = "download", format_id: str = "" + ) -> str: + """Download Twitter/X media + + Args: + url: Twitter/X post URL (required) + action + format_id""" + client = _get_client() + try: + response = client.tools.download_twitter_media( + url=url, action=action, format_id=format_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_download_facebook_video(url: str) -> str: + """Download Facebook video + + Args: + url: Facebook video or reel URL (required)""" + client = _get_client() + try: + response = client.tools.download_facebook_video(url=url) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_download_linked_in_video(url: str) -> str: + """Download LinkedIn video + + Args: + url: LinkedIn post URL (required)""" + client = _get_client() + try: + response = client.tools.download_linked_in_video(url=url) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def tools_download_bluesky_media(url: str) -> str: + """Download Bluesky media + + Args: + url: Bluesky post URL (required)""" + client = _get_client() + try: + response = client.tools.download_bluesky_media(url=url) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # TWITTER_ENGAGEMENT + + @mcp.tool() + def twitter_engagement_retweet_post(account_id: str, tweet_id: str) -> str: + """Retweet a post + + Args: + account_id: The social account ID (required) + tweet_id: The ID of the tweet to retweet (required)""" + client = _get_client() + try: + response = client.twitter_engagement.retweet_post( + accountId=account_id, tweetId=tweet_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def twitter_engagement_undo_retweet(account_id: str, tweet_id: str) -> str: + """Undo retweet + + Args: + account_id: (required) + tweet_id: The ID of the original tweet to un-retweet (required)""" + client = _get_client() + try: + response = client.twitter_engagement.undo_retweet( + account_id=account_id, tweet_id=tweet_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def twitter_engagement_bookmark_post(account_id: str, tweet_id: str) -> str: + """Bookmark a tweet + + Args: + account_id: The social account ID (required) + tweet_id: The ID of the tweet to bookmark (required)""" + client = _get_client() + try: + response = client.twitter_engagement.bookmark_post( + accountId=account_id, tweetId=tweet_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def twitter_engagement_remove_bookmark(account_id: str, tweet_id: str) -> str: + """Remove bookmark + + Args: + account_id: (required) + tweet_id: The ID of the tweet to unbookmark (required)""" + client = _get_client() + try: + response = client.twitter_engagement.remove_bookmark( + account_id=account_id, tweet_id=tweet_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def twitter_engagement_follow_user(account_id: str, target_user_id: str) -> str: + """Follow a user + + Args: + account_id: The social account ID (required) + target_user_id: The Twitter ID of the user to follow (required)""" + client = _get_client() + try: + response = client.twitter_engagement.follow_user( + accountId=account_id, targetUserId=target_user_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def twitter_engagement_unfollow_user(account_id: str, target_user_id: str) -> str: + """Unfollow a user + + Args: + account_id: (required) + target_user_id: The Twitter ID of the user to unfollow (required)""" + client = _get_client() + try: + response = client.twitter_engagement.unfollow_user( + account_id=account_id, target_user_id=target_user_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # USAGE + + @mcp.tool() + def usage_get_usage_stats() -> str: + """Get plan and usage stats""" + client = _get_client() + try: + response = client.usage.get_usage_stats() + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # USERS + + @mcp.tool() + def users_list_users() -> str: + """List users""" + client = _get_client() + try: + response = client.users.list_users() + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def users_get_user(user_id: str) -> str: + """Get user + + Args: + user_id: (required)""" + client = _get_client() + try: + response = client.users.get_user(user_id=user_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # VALIDATE + + @mcp.tool() + def validate_post_length(text: str) -> str: + """Validate post character count + + Args: + text: The post text to check (required)""" + client = _get_client() + try: + response = client.validate.validate_post_length(text=text) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def validate_post(platforms: str, content: str = "", media_items: str = "") -> str: + """Validate post content + + Args: + content: Post text content + platforms: Target platforms (same format as POST /v1/posts) (required) + media_items: Root media items shared across platforms""" + client = _get_client() + try: + response = client.validate.validate_post( + content=content, platforms=platforms, mediaItems=media_items + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def validate_media(url: str) -> str: + """Validate media URL + + Args: + url: Public media URL to validate (required)""" + client = _get_client() + try: + response = client.validate.validate_media(url=url) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def validate_subreddit(name: str) -> str: + """Check subreddit existence + + Args: + name: Subreddit name (with or without "r/" prefix) (required)""" + client = _get_client() + try: + response = client.validate.validate_subreddit(name=name) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # WEBHOOKS + + @mcp.tool() + def webhooks_get_webhook_settings() -> str: + """List webhooks""" + client = _get_client() + try: + response = client.webhooks.get_webhook_settings() + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def webhooks_create_webhook_settings( + name: str = "", + url: str = "", + secret: str = "", + events: str = "", + is_active: bool = False, + custom_headers: str = "", + ) -> str: + """Create webhook + + Args: + name: Webhook name (max 50 characters) + url: Webhook endpoint URL (must be HTTPS in production) + secret: Secret key for HMAC-SHA256 signature verification + events: Events to subscribe to + is_active: Enable or disable webhook delivery + custom_headers: Custom headers to include in webhook requests""" + client = _get_client() + try: + response = client.webhooks.create_webhook_settings( + name=name, + url=url, + secret=secret, + events=events, + isActive=is_active, + customHeaders=custom_headers, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def webhooks_update_webhook_settings( + id: str, + name: str = "", + url: str = "", + secret: str = "", + events: str = "", + is_active: bool = False, + custom_headers: str = "", + ) -> str: + """Update webhook + + Args: + id: Webhook ID to update (required) (required) + name: Webhook name (max 50 characters) + url: Webhook endpoint URL (must be HTTPS in production) + secret: Secret key for HMAC-SHA256 signature verification + events: Events to subscribe to + is_active: Enable or disable webhook delivery + custom_headers: Custom headers to include in webhook requests""" + client = _get_client() + try: + response = client.webhooks.update_webhook_settings( + _id=id, + name=name, + url=url, + secret=secret, + events=events, + isActive=is_active, + customHeaders=custom_headers, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def webhooks_delete_webhook_settings(id: str) -> str: + """Delete webhook + + Args: + id: Webhook ID to delete (required)""" + client = _get_client() + try: + response = client.webhooks.delete_webhook_settings(id=id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def webhooks_test_webhook(webhook_id: str) -> str: + """Send test webhook + + Args: + webhook_id: ID of the webhook to test (required)""" + client = _get_client() + try: + response = client.webhooks.test_webhook(webhookId=webhook_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def webhooks_get_webhook_logs( + limit: int = 50, status: str = "", event: str = "", webhook_id: str = "" + ) -> str: + """Get delivery logs + + Args: + limit: Maximum number of logs to return (max 100) + status: Filter by delivery status + event: Filter by event type + webhook_id: Filter by webhook ID""" + client = _get_client() + try: + response = client.webhooks.get_webhook_logs( + limit=limit, status=status, event=event, webhook_id=webhook_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # WHATSAPP + + @mcp.tool() + def whatsapp_send_whats_app_bulk( + account_id: str, recipients: str, template: str + ) -> str: + """Bulk send template messages + + Args: + account_id: WhatsApp social account ID (required) + recipients: List of recipients (max 100) (required) + template: (required)""" + client = _get_client() + try: + response = client.whatsapp.send_whats_app_bulk( + accountId=account_id, recipients=recipients, template=template + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_contacts( + account_id: str, + search: str = "", + tag: str = "", + group: str = "", + opted_in: str = "", + limit: int = 50, + skip: int = 0, + ) -> str: + """List contacts + + Args: + account_id: WhatsApp social account ID (required) + search: Search contacts by name, phone, email, or company + tag: Filter by tag + group: Filter by group + opted_in: Filter by opt-in status + limit: Maximum results (default 50) + skip: Offset for pagination""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_contacts( + account_id=account_id, + search=search, + tag=tag, + group=group, + opted_in=opted_in, + limit=limit, + skip=skip, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_create_whats_app_contact( + account_id: str, + phone: str, + name: str, + email: str = "", + company: str = "", + tags: str = "", + groups: str = "", + is_opted_in: bool = True, + custom_fields: str = "", + notes: str = "", + ) -> str: + """Create contact + + Args: + account_id: WhatsApp social account ID (required) + phone: Phone number in E.164 format (required) + name: Contact name (required) + email: Contact email + company: Company name + tags: Tags for categorization + groups: Groups the contact belongs to + is_opted_in: Whether the contact has opted in to receive messages + custom_fields: Custom key-value fields + notes: Notes about the contact""" + client = _get_client() + try: + response = client.whatsapp.create_whats_app_contact( + accountId=account_id, + phone=phone, + name=name, + email=email, + company=company, + tags=tags, + groups=groups, + isOptedIn=is_opted_in, + customFields=custom_fields, + notes=notes, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_contact(contact_id: str) -> str: + """Get contact + + Args: + contact_id: Contact ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_contact(contact_id=contact_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_update_whats_app_contact( + contact_id: str, + name: str = "", + email: str = "", + company: str = "", + tags: str = "", + groups: str = "", + is_opted_in: bool = False, + is_blocked: bool = False, + custom_fields: str = "", + notes: str = "", + ) -> str: + """Update contact + + Args: + contact_id: Contact ID (required) + name: Contact name + email: Contact email + company: Company name + tags: Tags (replaces existing) + groups: Groups (replaces existing) + is_opted_in: Opt-in status (changes are timestamped) + is_blocked: Block status + custom_fields: Custom fields to merge (set value to null to remove a field) + notes: Notes about the contact""" + client = _get_client() + try: + response = client.whatsapp.update_whats_app_contact( + contact_id=contact_id, + name=name, + email=email, + company=company, + tags=tags, + groups=groups, + isOptedIn=is_opted_in, + isBlocked=is_blocked, + customFields=custom_fields, + notes=notes, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_delete_whats_app_contact(contact_id: str) -> str: + """Delete contact + + Args: + contact_id: Contact ID (required)""" + client = _get_client() + try: + response = client.whatsapp.delete_whats_app_contact(contact_id=contact_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_import_whats_app_contacts( + account_id: str, + contacts: str, + default_tags: str = "", + default_groups: str = "", + skip_duplicates: bool = True, + ) -> str: + """Bulk import contacts + + Args: + account_id: WhatsApp social account ID (required) + contacts: Contacts to import (max 1000) (required) + default_tags: Tags applied to all imported contacts + default_groups: Groups applied to all imported contacts + skip_duplicates: Skip contacts with existing phone numbers""" + client = _get_client() + try: + response = client.whatsapp.import_whats_app_contacts( + accountId=account_id, + contacts=contacts, + defaultTags=default_tags, + defaultGroups=default_groups, + skipDuplicates=skip_duplicates, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_bulk_update_whats_app_contacts( + action: str, contact_ids: str, tags: str = "", groups: str = "" + ) -> str: + """Bulk update contacts + + Args: + action: Bulk action to perform (required) + contact_ids: Contact IDs to update (max 500) (required) + tags: Tags to add or remove (required for addTags/removeTags) + groups: Groups to add or remove (required for addGroups/removeGroups)""" + client = _get_client() + try: + response = client.whatsapp.bulk_update_whats_app_contacts( + action=action, contactIds=contact_ids, tags=tags, groups=groups + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_bulk_delete_whats_app_contacts(contact_ids: str) -> str: + """Bulk delete contacts + + Args: + contact_ids: Contact IDs to delete (max 500) (required)""" + client = _get_client() + try: + response = client.whatsapp.bulk_delete_whats_app_contacts( + contactIds=contact_ids + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_groups(account_id: str) -> str: + """List contact groups + + Args: + account_id: WhatsApp social account ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_groups(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_rename_whats_app_group( + account_id: str, old_name: str, new_name: str + ) -> str: + """Rename group + + Args: + account_id: WhatsApp social account ID (required) + old_name: Current group name (required) + new_name: New group name (required)""" + client = _get_client() + try: + response = client.whatsapp.rename_whats_app_group( + accountId=account_id, oldName=old_name, newName=new_name + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_delete_whats_app_group(account_id: str, group_name: str) -> str: + """Delete group + + Args: + account_id: WhatsApp social account ID (required) + group_name: Group name to delete (required)""" + client = _get_client() + try: + response = client.whatsapp.delete_whats_app_group( + accountId=account_id, groupName=group_name + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_templates(account_id: str) -> str: + """List templates + + Args: + account_id: WhatsApp social account ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_templates(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_create_whats_app_template( + account_id: str, + name: str, + category: str, + language: str, + components: str = "", + library_template_name: str = "", + library_template_body_inputs: str = "", + library_template_button_inputs: str = "", + ) -> str: + """Create template + + Args: + account_id: WhatsApp social account ID (required) + name: Template name (lowercase, letters/numbers/underscores, must start with a letter) (required) + category: Template category (required) + language: Template language code (e.g., en_US) (required) + components: Template components (header, body, footer, buttons). Required for custom templates, omit when using library_template_name. + library_template_name: Name of a pre-built template from Meta's template library (e.g., "appointment_reminder", + "auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved + by Meta with no review wait. Omit `components` when using this field. + library_template_body_inputs: Optional body customizations for library templates. Available options depend on the + template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation, + add_track_package_link, code_expiration_minutes). + library_template_button_inputs: Optional button customizations for library templates. Each item specifies button type + and configuration (e.g., URL, phone number, quick reply).""" + client = _get_client() + try: + response = client.whatsapp.create_whats_app_template( + accountId=account_id, + name=name, + category=category, + language=language, + components=components, + library_template_name=library_template_name, + library_template_body_inputs=library_template_body_inputs, + library_template_button_inputs=library_template_button_inputs, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_template(template_name: str, account_id: str) -> str: + """Get template + + Args: + template_name: Template name (required) + account_id: WhatsApp social account ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_template( + template_name=template_name, account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_update_whats_app_template( + template_name: str, account_id: str, components: str + ) -> str: + """Update template + + Args: + template_name: Template name (required) + account_id: WhatsApp social account ID (required) + components: Updated template components (required)""" + client = _get_client() + try: + response = client.whatsapp.update_whats_app_template( + template_name=template_name, accountId=account_id, components=components + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_delete_whats_app_template(template_name: str, account_id: str) -> str: + """Delete template + + Args: + template_name: Template name (required) + account_id: WhatsApp social account ID (required)""" + client = _get_client() + try: + response = client.whatsapp.delete_whats_app_template( + template_name=template_name, account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_broadcasts( + account_id: str, status: str = "", limit: int = 50, skip: int = 0 + ) -> str: + """List broadcasts + + Args: + account_id: WhatsApp social account ID (required) + status: Filter by broadcast status + limit: Maximum results (default 50) + skip: Offset for pagination""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_broadcasts( + account_id=account_id, status=status, limit=limit, skip=skip + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_create_whats_app_broadcast( + account_id: str, + name: str, + template: str, + description: str = "", + recipients: str = "", + ) -> str: + """Create broadcast + + Args: + account_id: WhatsApp social account ID (required) + name: Broadcast name (required) + description: Broadcast description + template: (required) + recipients: Initial recipients (optional)""" + client = _get_client() + try: + response = client.whatsapp.create_whats_app_broadcast( + accountId=account_id, + name=name, + description=description, + template=template, + recipients=recipients, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_broadcast(broadcast_id: str) -> str: + """Get broadcast + + Args: + broadcast_id: Broadcast ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_broadcast( + broadcast_id=broadcast_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_delete_whats_app_broadcast(broadcast_id: str) -> str: + """Delete broadcast + + Args: + broadcast_id: Broadcast ID (required)""" + client = _get_client() + try: + response = client.whatsapp.delete_whats_app_broadcast( + broadcast_id=broadcast_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_send_whats_app_broadcast(broadcast_id: str) -> str: + """Send broadcast + + Args: + broadcast_id: Broadcast ID (required)""" + client = _get_client() + try: + response = client.whatsapp.send_whats_app_broadcast( + broadcast_id=broadcast_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_schedule_whats_app_broadcast( + broadcast_id: str, scheduled_at: str + ) -> str: + """Schedule broadcast + + Args: + broadcast_id: Broadcast ID (required) + scheduled_at: ISO 8601 date-time for sending (must be in the future, max 30 days) (required)""" + client = _get_client() + try: + response = client.whatsapp.schedule_whats_app_broadcast( + broadcast_id=broadcast_id, scheduledAt=scheduled_at + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_cancel_whats_app_broadcast_schedule(broadcast_id: str) -> str: + """Cancel scheduled broadcast + + Args: + broadcast_id: Broadcast ID (required)""" + client = _get_client() + try: + response = client.whatsapp.cancel_whats_app_broadcast_schedule( + broadcast_id=broadcast_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_broadcast_recipients( + broadcast_id: str, status: str = "", limit: int = 100, skip: int = 0 + ) -> str: + """List recipients + + Args: + broadcast_id: Broadcast ID (required) + status: Filter by recipient delivery status + limit: Maximum results (default 100) + skip: Offset for pagination""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_broadcast_recipients( + broadcast_id=broadcast_id, status=status, limit=limit, skip=skip + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_add_whats_app_broadcast_recipients( + broadcast_id: str, recipients: str + ) -> str: + """Add recipients + + Args: + broadcast_id: Broadcast ID (required) + recipients: Recipients to add (max 1000) (required)""" + client = _get_client() + try: + response = client.whatsapp.add_whats_app_broadcast_recipients( + broadcast_id=broadcast_id, recipients=recipients + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_remove_whats_app_broadcast_recipients( + broadcast_id: str, phones: str + ) -> str: + """Remove recipients + + Args: + broadcast_id: Broadcast ID (required) + phones: Phone numbers to remove (required)""" + client = _get_client() + try: + response = client.whatsapp.remove_whats_app_broadcast_recipients( + broadcast_id=broadcast_id, phones=phones + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_business_profile(account_id: str) -> str: + """Get business profile + + Args: + account_id: WhatsApp social account ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_business_profile( + account_id=account_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_update_whats_app_business_profile( + account_id: str, + about: str = "", + address: str = "", + description: str = "", + email: str = "", + websites: str = "", + vertical: str = "", + profile_picture_handle: str = "", + ) -> str: + """Update business profile + + Args: + account_id: WhatsApp social account ID (required) + about: Short business description (max 139 characters) + address: Business address + description: Full business description (max 512 characters) + email: Business email + websites: Business websites (max 2) + vertical: Business category (e.g., RETAIL, ENTERTAINMENT, etc.) + profile_picture_handle: Handle from resumable upload for profile picture""" + client = _get_client() + try: + response = client.whatsapp.update_whats_app_business_profile( + accountId=account_id, + about=about, + address=address, + description=description, + email=email, + websites=websites, + vertical=vertical, + profilePictureHandle=profile_picture_handle, + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_upload_whats_app_profile_photo() -> str: + """Upload profile picture""" + client = _get_client() + try: + response = client.whatsapp.upload_whats_app_profile_photo() + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_get_whats_app_display_name(account_id: str) -> str: + """Get display name and review status + + Args: + account_id: WhatsApp social account ID (required)""" + client = _get_client() + try: + response = client.whatsapp.get_whats_app_display_name(account_id=account_id) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_update_whats_app_display_name( + account_id: str, display_name: str + ) -> str: + """Request display name change + + Args: + account_id: WhatsApp social account ID (required) + display_name: New display name (must follow WhatsApp naming guidelines) (required)""" + client = _get_client() + try: + response = client.whatsapp.update_whats_app_display_name( + accountId=account_id, displayName=display_name + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + # WHATSAPP_PHONE_NUMBERS + + @mcp.tool() + def whatsapp_phone_numbers_get_whats_app_phone_numbers( + status: str = "", profile_id: str = "" + ) -> str: + """List phone numbers + + Args: + status: Filter by status (by default excludes released numbers) + profile_id: Filter by profile""" + client = _get_client() + try: + response = client.whatsapp_phone_numbers.get_whats_app_phone_numbers( + status=status, profile_id=profile_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_phone_numbers_purchase_whats_app_phone_number(profile_id: str) -> str: + """Purchase phone number + + Args: + profile_id: Profile to associate the number with (required)""" + client = _get_client() + try: + response = client.whatsapp_phone_numbers.purchase_whats_app_phone_number( + profileId=profile_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_phone_numbers_get_whats_app_phone_number(phone_number_id: str) -> str: + """Get phone number + + Args: + phone_number_id: Phone number record ID (required)""" + client = _get_client() + try: + response = client.whatsapp_phone_numbers.get_whats_app_phone_number( + phone_number_id=phone_number_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" + + @mcp.tool() + def whatsapp_phone_numbers_release_whats_app_phone_number( + phone_number_id: str, + ) -> str: + """Release phone number + + Args: + phone_number_id: Phone number record ID (required)""" + client = _get_client() + try: + response = client.whatsapp_phone_numbers.release_whats_app_phone_number( + phone_number_id=phone_number_id + ) + return _format_response(response) + except Exception as e: + return f"Error: {e}" diff --git a/src/late/mcp/http_server.py b/src/late/mcp/http_server.py new file mode 100644 index 0000000..0d351af --- /dev/null +++ b/src/late/mcp/http_server.py @@ -0,0 +1,103 @@ +"""HTTP/SSE server for Late MCP.""" + +import argparse +import sys + +import uvicorn +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.routing import Mount, Route + +from late.mcp.config import ServerConfig, validate_environment +from late.mcp.constants import ( + ENDPOINT_HEALTH, + ENDPOINT_MESSAGES, + ENDPOINT_ROOT, + ENDPOINT_SSE, +) +from late.mcp.routes import create_sse_handler, handle_health, handle_root + + +def create_app(mcp_server, debug: bool = False) -> Starlette: + """ + Create Starlette application with SSE transport. + + Args: + mcp_server: MCP server instance + debug: Enable debug mode + + Returns: + Configured Starlette application + """ + sse_transport = SseServerTransport(ENDPOINT_MESSAGES) + sse_handler = create_sse_handler(mcp_server, sse_transport, debug) + + return Starlette( + debug=debug, + routes=[ + Route(ENDPOINT_ROOT, endpoint=handle_root, methods=["GET"]), + Route(ENDPOINT_HEALTH, endpoint=handle_health, methods=["GET"]), + Route(ENDPOINT_SSE, endpoint=sse_handler), + Mount(ENDPOINT_MESSAGES, app=sse_transport.handle_post_message), + ], + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Late MCP HTTP/SSE Server") + parser.add_argument("--host", help="Host to bind to (default: 0.0.0.0)") + parser.add_argument("--port", type=int, help="Port to listen on (default: 8080)") + parser.add_argument("--debug", action="store_true", help="Enable debug mode") + return parser.parse_args() + + +def print_startup_info(config: ServerConfig) -> None: + """Print server startup information.""" + print("šŸš€ Late MCP HTTP Server starting...") + print(f" Host: {config.host}") + print(f" Port: {config.port}") + print(f" SSE endpoint: http://{config.host}:{config.port}{ENDPOINT_SSE}") + print(f" Health check: http://{config.host}:{config.port}{ENDPOINT_HEALTH}") + print(f" Debug mode: {'enabled' if config.debug else 'disabled'}") + print() + print("šŸ“” Ready to accept MCP connections!") + + +def main() -> None: + """Entry point for HTTP/SSE server.""" + # Validate environment + validate_environment() + + # Parse arguments + args = parse_args() + + # Create configuration + config = ServerConfig.from_env(host=args.host, port=args.port, debug=args.debug) + + # Import and get MCP server + try: + from late.mcp.server import mcp + + mcp_server = mcp._mcp_server + except (ImportError, AttributeError) as e: + print(f"āŒ Failed to access MCP server: {e}", file=sys.stderr) + sys.exit(1) + + # Create app + app = create_app(mcp_server, debug=config.debug) + + # Print startup info + print_startup_info(config) + + # Run server + uvicorn.run( + app, + host=config.host, + port=config.port, + log_level="debug" if config.debug else "info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/late/mcp/routes.py b/src/late/mcp/routes.py new file mode 100644 index 0000000..2474398 --- /dev/null +++ b/src/late/mcp/routes.py @@ -0,0 +1,110 @@ +"""Route handlers for Late MCP HTTP server.""" + +import sys + +from mcp.server.sse import SseServerTransport +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from late.mcp.auth import extract_late_api_key, verify_late_api_key +from late.mcp.constants import ( + DOCS_URL, + ENDPOINT_HEALTH, + ENDPOINT_MESSAGES, + ENDPOINT_SSE, + SERVICE_NAME, + SERVICE_VERSION, + TRANSPORT_TYPE, +) + + +async def handle_root(_request: Request) -> JSONResponse: + """Root endpoint with server information.""" + return JSONResponse( + { + "service": SERVICE_NAME, + "version": SERVICE_VERSION, + "transport": TRANSPORT_TYPE, + "endpoints": { + "sse": f"{ENDPOINT_SSE} (GET) - SSE connection endpoint", + "messages": f"{ENDPOINT_MESSAGES} (POST) - Message handler", + "health": f"{ENDPOINT_HEALTH} (GET) - Health check", + }, + "documentation": DOCS_URL, + "authentication": "Required: 'Authorization: Bearer YOUR_LATE_API_KEY'", + } + ) + + +async def handle_health(_request: Request) -> JSONResponse: + """Health check endpoint (public, no auth required).""" + return JSONResponse( + { + "status": "healthy", + "service": "late-mcp-http", + "version": SERVICE_VERSION, + "transport": TRANSPORT_TYPE, + } + ) + + +def create_sse_handler(mcp_server, sse_transport: SseServerTransport, debug: bool = False): + """ + Create SSE connection handler. + + Args: + mcp_server: MCP server instance + sse_transport: SSE transport instance + debug: Enable debug logging + + Returns: + Async handler function + """ + + async def handle_sse(request: Request) -> Response: + """Handle SSE connection with authentication.""" + # Extract Late API key from request + late_api_key = extract_late_api_key(request) + if not late_api_key: + return JSONResponse( + {"error": "Missing Late API key. Provide via Authorization header: 'Authorization: Bearer YOUR_API_KEY'"}, + status_code=401 + ) + + # Verify Late API key by making test request + if not await verify_late_api_key(late_api_key): + return JSONResponse( + {"error": "Invalid Late API key"}, + status_code=401 + ) + + # Store API key in request state for use in MCP tools + request.state.late_api_key = late_api_key + + # Establish SSE connection + try: + # Import here to set context variable before running MCP + from late.mcp.server import set_late_api_key + + set_late_api_key(late_api_key) + + async with sse_transport.connect_sse( + request.scope, + request.receive, + request._send, + ) as (read_stream, write_stream): + await mcp_server.run( + read_stream, + write_stream, + mcp_server.create_initialization_options(), + ) + except Exception as e: + if debug: + print(f"āŒ SSE connection error: {e}", file=sys.stderr) + return JSONResponse( + {"error": "SSE connection failed"}, status_code=500 + ) + + return Response(status_code=200) + + return handle_sse diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 47956c5..0b46293 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -24,15 +24,26 @@ from __future__ import annotations import os +import re +from contextvars import ContextVar from datetime import datetime, timedelta from typing import Any +import httpx from mcp.server.fastmcp import FastMCP from late import Late, MediaType, PostStatus from .tool_definitions import use_tool_def +# Context variable to store the Late API key for the current connection +_late_api_key: ContextVar[str | None] = ContextVar("late_api_key", default=None) + +# Cache for documentation content +_docs_cache: dict[str, tuple[str, datetime]] = {} +_DOCS_URL = "https://docs.zernio.com/llms-full.txt" +_CACHE_TTL_HOURS = 24 + # Initialize MCP server mcp = FastMCP( "Late", @@ -44,15 +55,47 @@ - profiles_* : Manage profiles (groups of accounts) - posts_* : Create, list, update, delete posts - media_* : Upload images and videos +- docs_* : Search Late API documentation """, ) +def set_late_api_key(api_key: str) -> None: + """ + Set the Late API key for the current async context. + + Args: + api_key: The Late API key to use for this connection. + """ + _late_api_key.set(api_key) + + def _get_client() -> Late: - """Get Late client with API key from environment.""" - api_key = os.getenv("LATE_API_KEY", "") + """ + Get Late client with API key from context or environment. + + For HTTP/SSE connections, uses the API key from the current context. + For STDIO connections (Claude Desktop), falls back to LATE_API_KEY env var. + + Returns: + Late client instance. + + Raises: + ValueError: If no API key is available. + """ + # Try to get API key from context (HTTP/SSE mode) + api_key = _late_api_key.get() + + # Fall back to environment variable (STDIO mode for Claude Desktop) if not api_key: - raise ValueError("LATE_API_KEY environment variable is required") + api_key = os.getenv("LATE_API_KEY", "") + + if not api_key: + raise ValueError( + "Late API key is required. " + "For HTTP/SSE: provide via X-Late-API-Key header. " + "For STDIO: set LATE_API_KEY environment variable." + ) base_url = os.getenv("LATE_BASE_URL", None) return Late(api_key=api_key, base_url=base_url) @@ -71,7 +114,7 @@ def accounts_list() -> str: accounts = response.accounts or [] if not accounts: - return "No accounts connected. Connect accounts at https://getlate.dev" + return "No accounts connected. Connect accounts at https://zernio.com" lines = [f"Found {len(accounts)} connected account(s):\n"] for acc in accounts: @@ -160,7 +203,7 @@ def profiles_create(name: str, description: str = "", color: str = "") -> str: response = client.profiles.create(**params) profile = response.profile - return f"āœ… Profile created!\nName: {profile.name if profile else 'N/A'}\nID: {profile.field_id if profile else 'N/A'}" + return f"\u2705 Profile created!\nName: {profile.name if profile else 'N/A'}\nID: {profile.field_id if profile else 'N/A'}" @mcp.tool() @@ -185,12 +228,12 @@ def profiles_update( params["is_default"] = True if not params: - return "āš ļø No changes specified. Provide at least one field to update." + return "\u26a0\ufe0f No changes specified. Provide at least one field to update." response = client.profiles.update(profile_id, **params) profile = response.profile - return f"āœ… Profile updated!\nName: {profile.name if profile else 'N/A'}\nID: {profile.field_id if profile else 'N/A'}" + return f"\u2705 Profile updated!\nName: {profile.name if profile else 'N/A'}\nID: {profile.field_id if profile else 'N/A'}" @mcp.tool() @@ -198,7 +241,7 @@ def profiles_update( def profiles_delete(profile_id: str) -> str: client = _get_client() client.profiles.delete(profile_id) - return f"āœ… Profile {profile_id} deleted" + return f"\u2705 Profile {profile_id} deleted" # ============================================================================ @@ -340,12 +383,12 @@ def posts_create( post_id = post.field_id if post else "N/A" if is_draft: - return f"šŸ“ Draft saved for {platform} (@{username}){media_info}\nPost ID: {post_id}\nStatus: draft" + return f"\ud83d\udcdd Draft saved for {platform} (@{username}){media_info}\nPost ID: {post_id}\nStatus: draft" elif publish_now: - return f"āœ… Published to {platform} (@{username}){media_info}\nPost ID: {post_id}" + return f"\u2705 Published to {platform} (@{username}){media_info}\nPost ID: {post_id}" else: scheduled = params["scheduled_for"].strftime("%Y-%m-%d %H:%M") - return f"āœ… Scheduled for {platform} (@{username}){media_info}\nPost ID: {post_id}\nScheduled: {scheduled}" + return f"\u2705 Scheduled for {platform} (@{username}){media_info}\nPost ID: {post_id}\nScheduled: {scheduled}" @mcp.tool() @@ -428,12 +471,12 @@ def posts_cross_post( post_id = post.field_id if post else "N/A" if is_draft: - result = f"šŸ“ Draft saved for: {', '.join(posted_to)}{media_info}\nPost ID: {post_id}\nStatus: draft" + result = f"\ud83d\udcdd Draft saved for: {', '.join(posted_to)}{media_info}\nPost ID: {post_id}\nStatus: draft" else: - result = f"āœ… {'Published' if publish_now else 'Scheduled'} to: {', '.join(posted_to)}{media_info}\nPost ID: {post_id}" + result = f"\u2705 {'Published' if publish_now else 'Scheduled'} to: {', '.join(posted_to)}{media_info}\nPost ID: {post_id}" if not_found: - result += f"\nāš ļø Accounts not found for: {', '.join(not_found)}" + result += f"\n\u26a0\ufe0f Accounts not found for: {', '.join(not_found)}" return result @@ -457,14 +500,14 @@ def posts_update( params["title"] = title if not params: - return "āš ļø No changes specified. Provide at least one field to update." + return "\u26a0\ufe0f No changes specified. Provide at least one field to update." response = client.posts.update(post_id, **params) post = response.post post_id_str = post.field_id if post else "N/A" status = post.status if post else "N/A" - return f"āœ… Post updated!\nID: {post_id_str}\nStatus: {status}" + return f"\u2705 Post updated!\nID: {post_id_str}\nStatus: {status}" @mcp.tool() @@ -472,7 +515,7 @@ def posts_update( def posts_delete(post_id: str) -> str: client = _get_client() client.posts.delete(post_id) - return f"āœ… Post {post_id} deleted" + return f"\u2705 Post {post_id} deleted" @mcp.tool() @@ -484,17 +527,17 @@ def posts_retry(post_id: str) -> str: post_response = client.posts.get(post_id) post = post_response.post if not post: - return f"āŒ Post {post_id} not found" + return f"\u274c Post {post_id} not found" if post.status != PostStatus.FAILED: - return f"āš ļø Post {post_id} is not in failed status (current: {post.status})" + return f"\u26a0\ufe0f Post {post_id} is not in failed status (current: {post.status})" except Exception as e: - return f"āŒ Could not find post {post_id}: {e}" + return f"\u274c Could not find post {post_id}: {e}" try: client.posts.retry(post_id) - return f"āœ… Post {post_id} has been queued for retry" + return f"\u2705 Post {post_id} has been queued for retry" except Exception as e: - return f"āŒ Failed to retry post: {e}" + return f"\u274c Failed to retry post: {e}" @mcp.tool() @@ -541,11 +584,11 @@ def posts_retry_all_failed() -> str: success_count += 1 except Exception as e: fail_count += 1 - results.append(f"āŒ {post.field_id}: {e}") + results.append(f"\u274c {post.field_id}: {e}") - summary = f"āœ… Retried {success_count} post(s)" + summary = f"\u2705 Retried {success_count} post(s)" if fail_count > 0: - summary += f"\nāŒ Failed to retry {fail_count} post(s)" + summary += f"\n\u274c Failed to retry {fail_count} post(s)" summary += "\n" + "\n".join(results) return summary @@ -568,7 +611,7 @@ def media_generate_upload_link() -> str: token = response.token or "" expires_at = str(response.expiresAt) if response.expiresAt else "" - return f"""šŸ“¤ Upload link generated! + return f"""\ud83d\udce4 Upload link generated! **Open this link in your browser to upload files:** {upload_url} @@ -579,7 +622,7 @@ def media_generate_upload_link() -> str: Once you've uploaded your files, let me know and I'll check the status to get the URLs.""" except Exception as e: - return f"āŒ Failed to generate upload link: {e}" + return f"\u274c Failed to generate upload link: {e}" @mcp.tool() @@ -594,22 +637,22 @@ def media_check_upload_status(token: str) -> str: files = response.files or [] if status == "pending": - return f"""ā³ Upload pending + return f"""\u23f3 Upload pending The user hasn't uploaded files yet. Please wait for them to complete the upload in their browser. Token: {token}""" elif status == "expired": - return """ā° Upload link expired + return """\u23f0 Upload link expired The upload link has expired. Use media_generate_upload_link to create a new one.""" elif status == "completed": if not files: - return "āœ… Upload completed but no files were found." + return "\u2705 Upload completed but no files were found." - lines = [f"āœ… Upload completed! {len(files)} file(s) uploaded:\n"] + lines = [f"\u2705 Upload completed! {len(files)} file(s) uploaded:\n"] media_urls = [] for f in files: @@ -622,7 +665,7 @@ def media_check_upload_status(token: str) -> str: lines.append("") lines.append( - "\nšŸ“ You can now create a post with these media URLs using posts_create with the media_urls parameter." + "\n\ud83d\udcdd You can now create a post with these media URLs using posts_create with the media_urls parameter." ) lines.append(f"\nMedia URLs: {','.join(media_urls)}") @@ -632,12 +675,140 @@ def media_check_upload_status(token: str) -> str: return f"Unknown status: {status}" except Exception as e: - return f"āŒ Failed to check upload status: {e}" + return f"\u274c Failed to check upload status: {e}" + + +# ============================================================================ +# DOCS +# ============================================================================ + + +def _get_docs_content() -> str: + """Fetch and cache documentation content.""" + cache_key = "docs" + + # Check cache + if cache_key in _docs_cache: + content, cached_at = _docs_cache[cache_key] + if datetime.now() - cached_at < timedelta(hours=_CACHE_TTL_HOURS): + return content + + # Fetch fresh content + try: + response = httpx.get(_DOCS_URL, timeout=30.0) + response.raise_for_status() + content = response.text + _docs_cache[cache_key] = (content, datetime.now()) + return content + except Exception as e: + # Return cached content if available, even if expired + if cache_key in _docs_cache: + return _docs_cache[cache_key][0] + raise RuntimeError(f"Failed to fetch documentation: {e}") from e + + +def _search_docs(content: str, query: str, max_results: int = 5) -> list[dict[str, str]]: + """Search documentation content for relevant sections.""" + results: list[dict[str, str]] = [] + query_lower = query.lower() + query_terms = query_lower.split() + + # Split content into sections (by markdown headers) + sections = re.split(r'\n(?=#{1,3} )', content) + + scored_sections: list[tuple[int, str, str]] = [] + + for section in sections: + if not section.strip(): + continue + + section_lower = section.lower() + + # Calculate relevance score + score = 0 + + # Exact phrase match (highest priority) + if query_lower in section_lower: + score += 100 + + # Individual term matches + for term in query_terms: + if term in section_lower: + score += 10 + # Bonus for term in header + first_line = section.split('\n')[0].lower() + if term in first_line: + score += 20 + + if score > 0: + # Extract title from first line + lines = section.strip().split('\n') + title = lines[0].lstrip('#').strip() if lines else "Untitled" + scored_sections.append((score, title, section.strip())) + + # Sort by score and take top results + scored_sections.sort(key=lambda x: x[0], reverse=True) + + for score, title, section_text in scored_sections[:max_results]: + # Truncate long sections + if len(section_text) > 1500: + section_text = section_text[:1500] + "\n...(truncated)" + + results.append({ + "title": title, + "content": section_text, + "relevance": str(score), + }) + + return results + + +@mcp.tool() +@use_tool_def("docs_search") +def docs_search(query: str) -> str: + try: + content = _get_docs_content() + results = _search_docs(content, query) + + if not results: + return f"No documentation found for '{query}'. Try different search terms." + + lines = [f"Found {len(results)} relevant section(s) for '{query}':\n"] + + for i, result in enumerate(results, 1): + lines.append(f"--- Result {i}: {result['title']} ---") + lines.append(result["content"]) + lines.append("") + + return "\n".join(lines) + + except Exception as e: + return f"\u274c Failed to search documentation: {e}" + + +# ============================================================================ +# AUTO-GENERATED TOOLS +# ============================================================================ + +# Import and register auto-generated tools from OpenAPI spec +# These complement the custom tools above with full API coverage +try: + from .generated_tools import register_generated_tools + register_generated_tools(mcp, _get_client) +except ImportError: + # generated_tools.py not yet created - run scripts/generate_mcp_tools.py + pass # ============================================================================ # MAIN # ============================================================================ + +def main() -> None: + """Entry point for STDIO transport (Claude Desktop).""" + mcp.run(transport="stdio") + + if __name__ == "__main__": - mcp.run() + main() diff --git a/src/late/mcp/tool_definitions.py b/src/late/mcp/tool_definitions.py index a50f61e..4577afe 100644 --- a/src/late/mcp/tool_definitions.py +++ b/src/late/mcp/tool_definitions.py @@ -543,6 +543,28 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: params=[], ) +# ============================================================================= +# DOCS TOOLS +# ============================================================================= + +DOCS_SEARCH = ToolDef( + name="docs_search", + summary="Search the Late API documentation.", + description="""Search across the Late API documentation to find relevant information, code examples, API references, and guides. + +Use this tool when you need to answer questions about Late, find specific documentation, understand how features work, or locate implementation details. + +The search returns contextual content with section titles and relevant snippets.""", + params=[ + ParamDef( + name="query", + type="str", + description="Search query (e.g., 'webhooks', 'create post', 'authentication')", + required=True, + ), + ], +) + # ============================================================================= # MEDIA TOOLS # ============================================================================= @@ -608,6 +630,8 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: # Media "media_generate_upload_link": MEDIA_GENERATE_UPLOAD_LINK, "media_check_upload_status": MEDIA_CHECK_UPLOAD_STATUS, + # Docs + "docs_search": DOCS_SEARCH, } @@ -648,6 +672,7 @@ def generate_mdx_tools_reference() -> str: "posts_retry_all_failed", ], "Media": ["media_generate_upload_link", "media_check_upload_status"], + "Docs": ["docs_search"], } for category, tool_names in categories.items(): diff --git a/src/late/models/__init__.py b/src/late/models/__init__.py index 2795ce7..56bc4de 100644 --- a/src/late/models/__init__.py +++ b/src/late/models/__init__.py @@ -60,7 +60,7 @@ # Enums Status, # Platform-specific - TikTokSettings, + TikTokPlatformData, TranscriptResponse, TranscriptSegment, TwitterPlatformData, @@ -95,7 +95,7 @@ "Type", "Visibility", # Platform-specific - "TikTokSettings", + "TikTokPlatformData", "TwitterPlatformData", "InstagramPlatformData", "FacebookPlatformData", diff --git a/src/late/models/_generated/models.py b/src/late/models/_generated/models.py index 07cc92f..970021e 100644 --- a/src/late/models/_generated/models.py +++ b/src/late/models/_generated/models.py @@ -1,9 +1,10 @@ # generated by datamodel-codegen: -# filename: public-api.yaml -# timestamp: 2025-12-15T16:41:41+00:00 +# filename: openapi.yaml +# timestamp: 2026-03-17T11:30:30+00:00 from __future__ import annotations +from datetime import date as date_aliased from enum import Enum from typing import Annotated, Any, Dict, List @@ -15,6 +16,876 @@ class ErrorResponse(BaseModel): details: Dict[str, Any] | None = None +class FoodMenuLabel(BaseModel): + displayName: str + """ + Display name of the item/section/menu + """ + description: str | None = None + """ + Optional description + """ + languageCode: str | None = None + """ + BCP-47 language code (e.g. en, es) + """ + + +class Money(BaseModel): + currencyCode: str + """ + ISO 4217 currency code (e.g. USD, EUR) + """ + units: str + """ + Whole units of the amount + """ + nanos: int | None = None + """ + Nano units (10^-9) of the amount + """ + + +class FoodMenuItemAttributes(BaseModel): + price: Money | None = None + spiciness: str | None = None + """ + Spiciness level (e.g. MILD, MEDIUM, HOT) + """ + allergen: List[str] | None = None + """ + Allergens (e.g. DAIRY, GLUTEN, SHELLFISH) + """ + dietaryRestriction: List[str] | None = None + """ + Dietary labels (e.g. VEGETARIAN, VEGAN, GLUTEN_FREE) + """ + servesNumPeople: int | None = None + """ + Number of people the item serves + """ + preparationMethods: List[str] | None = None + """ + Preparation methods (e.g. GRILLED, FRIED) + """ + mediaKeys: List[str] | None = None + """ + Media references for item photos + """ + + +class Option(BaseModel): + labels: List[FoodMenuLabel] | None = None + attributes: FoodMenuItemAttributes | None = None + + +class FoodMenuItem(BaseModel): + labels: List[FoodMenuLabel] + attributes: FoodMenuItemAttributes | None = None + options: List[Option] | None = None + """ + Item variants/options (e.g. sizes, preparations) + """ + + +class FoodMenuSection(BaseModel): + labels: List[FoodMenuLabel] + items: List[FoodMenuItem] | None = None + + +class FoodMenu(BaseModel): + labels: List[FoodMenuLabel] + sections: List[FoodMenuSection] | None = None + cuisines: List[str] | None = None + """ + Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE) + """ + sourceUrl: str | None = None + """ + URL of the original menu source + """ + + +class DateRange(BaseModel): + startDate: date_aliased | None = None + endDate: date_aliased | None = None + + +class DailyView(BaseModel): + date: date_aliased | None = None + views: int | None = None + estimatedMinutesWatched: float | None = None + averageViewDuration: float | None = None + """ + Average view duration in seconds + """ + subscribersGained: int | None = None + subscribersLost: int | None = None + likes: int | None = None + comments: int | None = None + shares: int | None = None + + +class ScopeStatus(BaseModel): + hasAnalyticsScope: bool | None = None + + +class YouTubeDailyViewsResponse(BaseModel): + success: Annotated[bool | None, Field(examples=[True])] = None + videoId: str | None = None + """ + The YouTube video ID + """ + dateRange: DateRange | None = None + totalViews: int | None = None + """ + Sum of views across all days in the range + """ + dailyViews: List[DailyView] | None = None + lastSyncedAt: AwareDatetime | None = None + """ + When the data was last synced from YouTube + """ + scopeStatus: ScopeStatus | None = None + + +class ScopeStatus1(BaseModel): + hasAnalyticsScope: Annotated[bool | None, Field(examples=[False])] = None + requiresReauthorization: Annotated[bool | None, Field(examples=[True])] = None + reauthorizeUrl: AnyUrl | None = None + """ + URL to redirect user for reauthorization + """ + + +class YouTubeScopeMissingResponse(BaseModel): + success: Annotated[bool | None, Field(examples=[False])] = None + error: Annotated[ + str | None, + Field( + examples=[ + "To access daily video analytics, please reconnect your YouTube account to grant the required permissions." + ] + ), + ] = None + code: Annotated[str | None, Field(examples=["youtube_analytics_scope_missing"])] = ( + None + ) + scopeStatus: ScopeStatus1 | None = None + + +class Event(Enum): + POST_SCHEDULED = "post.scheduled" + POST_PUBLISHED = "post.published" + POST_FAILED = "post.failed" + POST_PARTIAL = "post.partial" + POST_RECYCLED = "post.recycled" + ACCOUNT_CONNECTED = "account.connected" + ACCOUNT_DISCONNECTED = "account.disconnected" + MESSAGE_RECEIVED = "message.received" + COMMENT_RECEIVED = "comment.received" + + +class Webhook(BaseModel): + """ + Individual webhook configuration for receiving real-time notifications + """ + + field_id: Annotated[str | None, Field(alias="_id")] = None + """ + Unique webhook identifier + """ + name: Annotated[str | None, Field(max_length=50)] = None + """ + Webhook name (for identification) + """ + url: AnyUrl | None = None + """ + Webhook endpoint URL + """ + secret: str | None = None + """ + Secret key for HMAC-SHA256 signature (not returned in responses for security) + """ + events: List[Event] | None = None + """ + Events subscribed to + """ + isActive: bool | None = None + """ + Whether webhook delivery is enabled + """ + lastFiredAt: AwareDatetime | None = None + """ + Timestamp of last successful webhook delivery + """ + failureCount: int | None = None + """ + Consecutive delivery failures (resets on success, webhook disabled at 10) + """ + customHeaders: Dict[str, str] | None = None + """ + Custom headers included in webhook requests + """ + + +class Event1(Enum): + POST_SCHEDULED = "post.scheduled" + POST_PUBLISHED = "post.published" + POST_FAILED = "post.failed" + POST_PARTIAL = "post.partial" + POST_RECYCLED = "post.recycled" + ACCOUNT_CONNECTED = "account.connected" + ACCOUNT_DISCONNECTED = "account.disconnected" + MESSAGE_RECEIVED = "message.received" + COMMENT_RECEIVED = "comment.received" + WEBHOOK_TEST = "webhook.test" + + +class Status(Enum): + SUCCESS = "success" + FAILED = "failed" + + +class WebhookLog(BaseModel): + """ + Webhook delivery log entry + """ + + field_id: Annotated[str | None, Field(alias="_id")] = None + webhookId: str | None = None + """ + ID of the webhook that was triggered + """ + webhookName: str | None = None + """ + Name of the webhook that was triggered + """ + event: Event1 | None = None + url: AnyUrl | None = None + status: Status | None = None + statusCode: int | None = None + """ + HTTP status code from webhook endpoint + """ + requestPayload: Dict[str, Any] | None = None + """ + Payload sent to webhook endpoint + """ + responseBody: str | None = None + """ + Response body from webhook endpoint (truncated to 10KB) + """ + errorMessage: str | None = None + """ + Error message if delivery failed + """ + attemptNumber: int | None = None + """ + Delivery attempt number (max 3 retries) + """ + responseTime: int | None = None + """ + Response time in milliseconds + """ + createdAt: AwareDatetime | None = None + + +class Event2(Enum): + POST_SCHEDULED = "post.scheduled" + POST_PUBLISHED = "post.published" + POST_FAILED = "post.failed" + POST_PARTIAL = "post.partial" + POST_RECYCLED = "post.recycled" + + +class Platform(BaseModel): + platform: str | None = None + status: str | None = None + publishedUrl: str | None = None + error: str | None = None + + +class Post(BaseModel): + id: str | None = None + content: str | None = None + status: str | None = None + scheduledFor: AwareDatetime | None = None + publishedAt: AwareDatetime | None = None + platforms: List[Platform] | None = None + + +class WebhookPayloadPost(BaseModel): + """ + Webhook payload for post events + """ + + event: Event2 | None = None + post: Post | None = None + timestamp: AwareDatetime | None = None + + +class Event3(Enum): + ACCOUNT_CONNECTED = "account.connected" + + +class Account(BaseModel): + accountId: str | None = None + """ + The account's unique identifier (same as used in /v1/accounts/{accountId}) + """ + profileId: str | None = None + """ + The profile's unique identifier this account belongs to + """ + platform: str | None = None + username: str | None = None + displayName: str | None = None + + +class WebhookPayloadAccountConnected(BaseModel): + """ + Webhook payload for account connected events + """ + + event: Event3 | None = None + account: Account | None = None + timestamp: AwareDatetime | None = None + + +class Event4(Enum): + ACCOUNT_DISCONNECTED = "account.disconnected" + + +class DisconnectionType(Enum): + """ + Whether the disconnection was intentional (user action) or unintentional (token expired/revoked) + """ + + INTENTIONAL = "intentional" + UNINTENTIONAL = "unintentional" + + +class Account1(BaseModel): + accountId: str | None = None + """ + The account's unique identifier (same as used in /v1/accounts/{accountId}) + """ + profileId: str | None = None + """ + The profile's unique identifier this account belongs to + """ + platform: str | None = None + username: str | None = None + displayName: str | None = None + disconnectionType: DisconnectionType | None = None + """ + Whether the disconnection was intentional (user action) or unintentional (token expired/revoked) + """ + reason: str | None = None + """ + Human-readable reason for the disconnection + """ + + +class WebhookPayloadAccountDisconnected(BaseModel): + """ + Webhook payload for account disconnected events + """ + + event: Event4 | None = None + account: Account1 | None = None + timestamp: AwareDatetime | None = None + + +class Event5(Enum): + COMMENT_RECEIVED = "comment.received" + + +class Platform1(Enum): + INSTAGRAM = "instagram" + FACEBOOK = "facebook" + TWITTER = "twitter" + YOUTUBE = "youtube" + LINKEDIN = "linkedin" + BLUESKY = "bluesky" + REDDIT = "reddit" + + +class Author(BaseModel): + id: str | None = None + """ + Author's platform ID + """ + username: str | None = None + name: str | None = None + picture: str | None = None + + +class Comment(BaseModel): + id: str | None = None + """ + Platform comment ID + """ + postId: str | None = None + """ + Internal post ID + """ + platformPostId: str | None = None + """ + Platform's post ID + """ + platform: Platform1 | None = None + text: str | None = None + """ + Comment text content + """ + author: Author | None = None + createdAt: AwareDatetime | None = None + isReply: bool | None = None + """ + Whether this is a reply to another comment + """ + parentCommentId: str | None = None + """ + Parent comment ID if this is a reply + """ + + +class Post1(BaseModel): + id: str | None = None + """ + Internal post ID + """ + platformPostId: str | None = None + """ + Platform's post ID + """ + + +class Account2(BaseModel): + id: str | None = None + """ + Social account ID + """ + platform: str | None = None + username: str | None = None + + +class WebhookPayloadComment(BaseModel): + """ + Webhook payload for comment received events (Instagram, Facebook, Twitter/X, YouTube, LinkedIn, Bluesky, Reddit) + """ + + event: Event5 | None = None + comment: Comment | None = None + post: Post1 | None = None + account: Account2 | None = None + timestamp: AwareDatetime | None = None + + +class Event6(Enum): + MESSAGE_RECEIVED = "message.received" + + +class Platform2(Enum): + INSTAGRAM = "instagram" + FACEBOOK = "facebook" + TELEGRAM = "telegram" + BLUESKY = "bluesky" + REDDIT = "reddit" + + +class Direction(Enum): + INCOMING = "incoming" + + +class Attachment(BaseModel): + type: str | None = None + """ + Attachment type (image, video, file, sticker, audio) + """ + url: str | None = None + """ + Attachment URL (may expire for Meta platforms) + """ + payload: Dict[str, Any] | None = None + """ + Additional attachment metadata + """ + + +class InstagramProfile(BaseModel): + """ + Instagram profile data for the sender. Only present for Instagram conversations. + """ + + isFollower: bool | None = None + """ + Whether the sender follows your Instagram business account + """ + isFollowing: bool | None = None + """ + Whether your Instagram business account follows the sender + """ + followerCount: int | None = None + """ + The sender's follower count on Instagram + """ + isVerified: bool | None = None + """ + Whether the sender is a verified Instagram user + """ + + +class Sender(BaseModel): + id: str | None = None + name: str | None = None + username: str | None = None + picture: str | None = None + instagramProfile: InstagramProfile | None = None + """ + Instagram profile data for the sender. Only present for Instagram conversations. + """ + + +class Message(BaseModel): + id: str | None = None + """ + Internal message ID + """ + conversationId: str | None = None + """ + Internal conversation ID + """ + platform: Platform2 | None = None + platformMessageId: str | None = None + """ + Platform's message ID + """ + direction: Direction | None = None + text: str | None = None + """ + Message text content + """ + attachments: List[Attachment] | None = None + sender: Sender | None = None + sentAt: AwareDatetime | None = None + isRead: bool | None = None + + +class Status1(Enum): + ACTIVE = "active" + ARCHIVED = "archived" + + +class Conversation(BaseModel): + id: str | None = None + platformConversationId: str | None = None + participantId: str | None = None + participantName: str | None = None + participantUsername: str | None = None + participantPicture: str | None = None + status: Status1 | None = None + + +class Account3(BaseModel): + id: str | None = None + """ + Social account ID + """ + platform: str | None = None + username: str | None = None + displayName: str | None = None + + +class Metadata(BaseModel): + """ + Interactive message metadata (present when message is a quick reply tap, postback button tap, or inline keyboard callback) + """ + + quickReplyPayload: str | None = None + """ + Payload from a quick reply tap (Meta platforms) + """ + postbackPayload: str | None = None + """ + Payload from a postback button tap (Meta platforms) + """ + postbackTitle: str | None = None + """ + Title of the tapped postback button (Meta platforms) + """ + callbackData: str | None = None + """ + Callback data from an inline keyboard button tap (Telegram) + """ + + +class WebhookPayloadMessage(BaseModel): + """ + Webhook payload for message received events (DMs from Instagram, Facebook, Telegram, Bluesky, Reddit) + """ + + event: Event6 | None = None + message: Message | None = None + conversation: Conversation | None = None + account: Account3 | None = None + metadata: Metadata | None = None + """ + Interactive message metadata (present when message is a quick reply tap, postback button tap, or inline keyboard callback) + """ + timestamp: AwareDatetime | None = None + + +class PostId(BaseModel): + """ + Populated post reference + """ + + field_id: Annotated[str | None, Field(alias="_id")] = None + content: str | None = None + status: str | None = None + + +class Platform3(Enum): + TIKTOK = "tiktok" + INSTAGRAM = "instagram" + FACEBOOK = "facebook" + YOUTUBE = "youtube" + LINKEDIN = "linkedin" + TWITTER = "twitter" + THREADS = "threads" + PINTEREST = "pinterest" + REDDIT = "reddit" + BLUESKY = "bluesky" + GOOGLEBUSINESS = "googlebusiness" + TELEGRAM = "telegram" + SNAPCHAT = "snapchat" + + +class Action(Enum): + """ + Type of action logged: publish (initial attempt), retry (after failure), media_upload, rate_limit_pause, token_refresh, cancelled + """ + + PUBLISH = "publish" + RETRY = "retry" + MEDIA_UPLOAD = "media_upload" + RATE_LIMIT_PAUSE = "rate_limit_pause" + TOKEN_REFRESH = "token_refresh" + CANCELLED = "cancelled" + + +class Status2(Enum): + SUCCESS = "success" + FAILED = "failed" + PENDING = "pending" + SKIPPED = "skipped" + + +class Request(BaseModel): + contentPreview: str | None = None + """ + First 200 chars of caption + """ + mediaCount: int | None = None + mediaTypes: List[str] | None = None + mediaUrls: List[str] | None = None + """ + URLs of media items sent to platform + """ + scheduledFor: AwareDatetime | None = None + rawBody: str | None = None + """ + Full request body JSON (max 5000 chars) + """ + + +class Response(BaseModel): + platformPostId: str | None = None + """ + ID returned by platform on success + """ + platformPostUrl: str | None = None + """ + URL of published post + """ + errorMessage: str | None = None + """ + Error message on failure + """ + errorCode: str | None = None + """ + Platform-specific error code + """ + rawBody: str | None = None + """ + Full response body JSON (max 5000 chars) + """ + + +class PostLog(BaseModel): + """ + Publishing log entry showing details of a post publishing attempt + """ + + field_id: Annotated[str | None, Field(alias="_id")] = None + postId: str | PostId | None = None + userId: str | None = None + profileId: str | None = None + platform: Platform3 | None = None + accountId: str | None = None + accountUsername: str | None = None + action: Action | None = None + """ + Type of action logged: publish (initial attempt), retry (after failure), media_upload, rate_limit_pause, token_refresh, cancelled + """ + status: Status2 | None = None + statusCode: int | None = None + """ + HTTP status code from platform API + """ + endpoint: str | None = None + """ + Platform API endpoint called + """ + request: Request | None = None + response: Response | None = None + durationMs: int | None = None + """ + How long the operation took in milliseconds + """ + attemptNumber: int | None = None + """ + Attempt number (1 for first try, 2+ for retries) + """ + createdAt: AwareDatetime | None = None + + +class EventType(Enum): + """ + Type of connection event: connect_success, connect_failed, disconnect, reconnect_success, reconnect_failed + """ + + CONNECT_SUCCESS = "connect_success" + CONNECT_FAILED = "connect_failed" + DISCONNECT = "disconnect" + RECONNECT_SUCCESS = "reconnect_success" + RECONNECT_FAILED = "reconnect_failed" + + +class ConnectionMethod(Enum): + """ + How the connection was initiated + """ + + OAUTH = "oauth" + CREDENTIALS = "credentials" + INVITATION = "invitation" + + +class Error(BaseModel): + """ + Error details (present on failed events) + """ + + code: str | None = None + """ + Error code (e.g., oauth_denied, token_exchange_failed) + """ + message: str | None = None + """ + Human-readable error message + """ + rawResponse: str | None = None + """ + Raw error response (truncated to 2000 chars) + """ + + +class Success(BaseModel): + """ + Success details (present on successful events) + """ + + displayName: str | None = None + username: str | None = None + profilePicture: str | None = None + permissions: List[str] | None = None + """ + OAuth scopes/permissions granted + """ + tokenExpiresAt: AwareDatetime | None = None + accountType: str | None = None + """ + Account type (personal, business, organization) + """ + + +class Context(BaseModel): + """ + Additional context about the connection attempt + """ + + isHeadlessMode: bool | None = None + hasCustomRedirectUrl: bool | None = None + isReconnection: bool | None = None + isBYOK: bool | None = None + """ + Using bring-your-own-keys + """ + invitationToken: str | None = None + connectToken: str | None = None + + +class ConnectionLog(BaseModel): + """ + Connection event log showing account connection/disconnection history + """ + + field_id: Annotated[str | None, Field(alias="_id")] = None + userId: str | None = None + """ + User who owns the connection (may be null for early OAuth failures) + """ + profileId: str | None = None + accountId: str | None = None + """ + The social account ID (present on successful connections and disconnects) + """ + platform: Platform3 | None = None + eventType: EventType | None = None + """ + Type of connection event: connect_success, connect_failed, disconnect, reconnect_success, reconnect_failed + """ + connectionMethod: ConnectionMethod | None = None + """ + How the connection was initiated + """ + error: Error | None = None + """ + Error details (present on failed events) + """ + success: Success | None = None + """ + Success details (present on successful events) + """ + context: Context | None = None + """ + Additional context about the connection attempt + """ + durationMs: int | None = None + """ + How long the operation took in milliseconds + """ + metadata: Dict[str, Any] | None = None + """ + Additional metadata + """ + createdAt: AwareDatetime | None = None + + class Type(Enum): IMAGE = "image" VIDEO = "video" @@ -24,22 +895,15 @@ class Type(Enum): class MediaItem(BaseModel): """ - Media referenced in posts. URLs must be publicly reachable over HTTPS by the destination platforms. - When using third‑party storage, ensure signed links remain valid until upload completes. - - **Uploading Media:** - - Small files (≤ ~4MB): Use `POST /v1/media` with `multipart/form-data` - - Large files (> ~4MB, up to 5GB): Use `POST /v1/media` with `Content-Type: application/json` for client-upload flow (presigned URL) - - See `/v1/media` endpoint documentation for details on both methods - - **Automatic Compression:** - - Bluesky: images larger than ~1MB are automatically recompressed to meet the platform's blob size limit. - - Instagram: images >8 MB and videos >100 MB (stories) or >300 MB (reels) are automatically compressed. - + Media referenced in posts. URLs must be publicly reachable over HTTPS. Use POST /v1/media/presign for uploads up to 5GB. Zernio auto-compresses images and videos that exceed platform limits (videos over 200 MB may not be compressed). """ type: Type | None = None url: AnyUrl | None = None + title: str | None = None + """ + Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename. + """ filename: str | None = None size: int | None = None """ @@ -51,7 +915,7 @@ class MediaItem(BaseModel): """ thumbnail: AnyUrl | None = None """ - Optional thumbnail image URL for videos + Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, and regular video uploads. Max 10MB, JPG/PNG recommended. """ instagramThumbnail: AnyUrl | None = None """ @@ -63,7 +927,32 @@ class MediaItem(BaseModel): """ -class Status(Enum): +class ErrorCategory(Enum): + """ + Error category for programmatic handling: auth_expired (token expired/revoked), user_content (wrong format/too long), user_abuse (rate limits/spam), account_issue (config problems), platform_rejected (policy violation), platform_error (5xx/maintenance), system_error (Zernio infra), unknown + """ + + AUTH_EXPIRED = "auth_expired" + USER_CONTENT = "user_content" + USER_ABUSE = "user_abuse" + ACCOUNT_ISSUE = "account_issue" + PLATFORM_REJECTED = "platform_rejected" + PLATFORM_ERROR = "platform_error" + SYSTEM_ERROR = "system_error" + UNKNOWN = "unknown" + + +class ErrorSource(Enum): + """ + Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare) + """ + + USER = "user" + PLATFORM = "platform" + SYSTEM = "system" + + +class Status3(Enum): DRAFT = "draft" SCHEDULED = "scheduled" PUBLISHING = "publishing" @@ -78,12 +967,138 @@ class Visibility(Enum): UNLISTED = "unlisted" +class GapFreq(Enum): + """ + Interval unit for the gap. Defaults to 'month'. + """ + + WEEK = "week" + MONTH = "month" + + +class RecyclingConfig(BaseModel): + """ + Configure automatic post recycling (reposting at regular intervals). + After the post is published, the system creates new scheduled copies at the + specified interval until expiration conditions are met. Supports weekly or + monthly intervals. Maximum 10 active recycling posts per account. + YouTube and TikTok platforms are excluded from recycling. + Content variations are recommended for Twitter and Pinterest to avoid duplicate flags. + + """ + + enabled: bool = True + """ + Set to false to disable recycling on this post + """ + gap: Annotated[int | None, Field(examples=[2], ge=1)] = None + """ + Number of interval units between each repost. Required when enabling recycling. + """ + gapFreq: GapFreq = "month" + """ + Interval unit for the gap. Defaults to 'month'. + """ + startDate: AwareDatetime | None = None + """ + When to start the recycling cycle. Defaults to the post's scheduledFor date. + """ + expireCount: Annotated[int | None, Field(examples=[5], ge=1)] = None + """ + Stop recycling after this many copies have been created + """ + expireDate: AwareDatetime | None = None + """ + Stop recycling after this date, regardless of count + """ + contentVariations: Annotated[List[str] | None, Field(max_length=20)] = None + """ + Array of content variations for recycled copies. On each recycle, the next + variation is used in round-robin order. Recommended for Twitter and Pinterest + to avoid duplicate content flags. If omitted, the original post content is + used for all recycled copies. Send an empty array [] to clear existing + variations. Must have 2+ entries when setting variations. Platform-level + customContent still overrides the base content per platform. + + """ + + +class GapFreq1(Enum): + """ + Interval unit (week or month) + """ + + WEEK = "week" + MONTH = "month" + + +class RecyclingState(BaseModel): + """ + Current recycling configuration and state on a post + """ + + enabled: bool | None = None + """ + Whether recycling is currently active + """ + gap: int | None = None + """ + Number of interval units between reposts + """ + gapFreq: GapFreq1 | None = None + """ + Interval unit (week or month) + """ + startDate: AwareDatetime | None = None + expireCount: int | None = None + expireDate: AwareDatetime | None = None + contentVariations: List[str] | None = None + """ + Content variations for recycled copies (if configured) + """ + contentVariationIndex: int | None = None + """ + Current position in the content variations rotation (read-only) + """ + recycleCount: int | None = None + """ + How many recycled copies have been created so far (read-only) + """ + nextRecycleAt: AwareDatetime | None = None + """ + When the next recycled copy will be created (read-only) + """ + lastRecycledAt: AwareDatetime | None = None + """ + When the last recycled copy was created (read-only) + """ + + +class ReplySettings(Enum): + """ + Controls who can reply to the tweet. "following" allows only people you follow, "mentionedUsers" allows only mentioned users, "subscribers" allows only subscribers, "verified" allows only verified users. Omit for default (everyone can reply). For threads, applies to the first tweet only. Cannot be combined with replyToTweetId. + """ + + FOLLOWING = "following" + MENTIONED_USERS = "mentionedUsers" + SUBSCRIBERS = "subscribers" + VERIFIED = "verified" + + class ThreadItem(BaseModel): content: str | None = None mediaItems: List[MediaItem] | None = None class TwitterPlatformData(BaseModel): + replyToTweetId: str | None = None + """ + ID of an existing tweet to reply to. The published tweet will appear as a reply in that tweet's thread. For threads, only the first tweet replies to the target; subsequent tweets chain normally. + """ + replySettings: ReplySettings | None = None + """ + Controls who can reply to the tweet. "following" allows only people you follow, "mentionedUsers" allows only mentioned users, "subscribers" allows only subscribers, "verified" allows only verified users. Omit for default (everyone can reply). For threads, applies to the first tweet only. Cannot be combined with replyToTweetId. + """ threadItems: List[ThreadItem] | None = None """ Sequence of tweets in a thread. First item is the root tweet. @@ -92,13 +1107,7 @@ class TwitterPlatformData(BaseModel): class ThreadsPlatformData(BaseModel): """ - Constraints: - - Carousel posts support up to 10 images (no videos in carousels). - - Single posts support one image or one video. - - Videos must be H.264/AAC MP4 format, max 5 minutes duration. - - Images must be JPEG or PNG, max 8 MB each. - - threadItems creates a reply chain (Threads equivalent of Twitter threads). - + Up to 10 images per carousel (no videos). Videos must be H.264/AAC MP4, max 5 min. Images JPEG/PNG, max 8 MB. Use threadItems for reply chains. """ threadItems: List[ThreadItem] | None = None @@ -109,34 +1118,33 @@ class ThreadsPlatformData(BaseModel): class ContentType(Enum): """ - Set to 'story' to publish as a Facebook Page Story (24-hour ephemeral content). Requires media. + Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. """ STORY = "story" + REEL = "reel" class FacebookPlatformData(BaseModel): """ - Constraints: - - Posts cannot mix videos and images. - - Multiple images supported via attached_media (up to 10 images for feed posts). - - Multiple videos in the same post are not supported. - - Stories require media (single image or video); text captions are not displayed with stories. - - Stories are ephemeral (disappear after 24 hours). - + Feed posts support up to 10 images (no mixed video+image). Stories require single media (24h, no captions). Reels require single vertical video (9:16, 3-60s). """ contentType: ContentType | None = None """ - Set to 'story' to publish as a Facebook Page Story (24-hour ephemeral content). Requires media. + Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. + """ + title: str | None = None + """ + Reel title (only for contentType=reel). Separate from the caption/content field. """ firstComment: str | None = None """ - Optional first comment to post immediately after publishing (feed posts only, not stories) + Optional first comment to post immediately after publishing (feed posts only, not stories or reels) """ pageId: str | None = None """ - Target Facebook Page ID. If omitted, uses the selected/default page on the connection. + Target Facebook Page ID for multi-page posting. If omitted, uses the default page. Use GET /v1/accounts/{id}/facebook-page to list pages. """ @@ -148,6 +1156,26 @@ class ContentType1(Enum): STORY = "story" +class GraduationStrategy(Enum): + """ + MANUAL (graduate from Instagram app) or SS_PERFORMANCE (auto-graduate if performs well with non-followers) + """ + + MANUAL = "MANUAL" + SS_PERFORMANCE = "SS_PERFORMANCE" + + +class TrialParams(BaseModel): + """ + Trial Reels configuration. Trial reels are shared to non-followers first and can later be graduated to regular reels manually or automatically based on performance. Only applies to Reels. + """ + + graduationStrategy: GraduationStrategy | None = None + """ + MANUAL (graduate from Instagram app) or SS_PERFORMANCE (auto-graduate if performs well with non-followers) + """ + + class UserTag(BaseModel): username: Annotated[str, Field(examples=["friend_username"])] """ @@ -161,25 +1189,15 @@ class UserTag(BaseModel): """ Y coordinate position from top edge (0.0 = top, 0.5 = center, 1.0 = bottom) """ + mediaIndex: Annotated[int | None, Field(examples=[0], ge=0)] = None + """ + Zero-based index of the carousel item to tag. Defaults to 0. Tags on video items or out-of-range indices are ignored. + """ class InstagramPlatformData(BaseModel): """ - Constraints: - - Feed posts require images with aspect ratio between 0.8 (4:5 portrait) and 1.91 (1.91:1 landscape). - - Images outside this range (e.g., 9:16 Stories/TikTok format) must use contentType 'story'. - - Validation happens at post creation; invalid images are rejected immediately with helpful error messages. - - Carousels support up to 10 media items. - - Stories require media; no captions are published with Stories. - - User tags: coordinates range from 0.0 to 1.0 representing position from top-left corner. Tagged users receive notifications. - - **Automatic Compression (similar to Bluesky):** - - All images (story, post, carousel, thumbnails) exceeding 8 MB are automatically compressed using quality reduction and resizing. - - Story videos exceeding 100 MB are automatically compressed. - - Reel videos exceeding 300 MB are automatically compressed. - - Compression uses Sharp (images) and FFmpeg (videos) to maintain quality while meeting size limits. - - Original files are preserved; compressed versions are uploaded to blob storage automatically. - + Feed aspect ratio 0.8-1.91, carousels up to 10 items, stories require media (no captions). User tag coordinates 0.0-1.0 from top-left. Images over 8 MB and videos over platform limits are auto-compressed. """ contentType: ContentType1 | None = None @@ -198,23 +1216,37 @@ class InstagramPlatformData(BaseModel): """ Optional first comment to add after the post is created (not applied to Stories) """ + trialParams: TrialParams | None = None + """ + Trial Reels configuration. Trial reels are shared to non-followers first and can later be graduated to regular reels manually or automatically based on performance. Only applies to Reels. + """ userTags: List[UserTag] | None = None """ - Tag Instagram users in photos by username and position coordinates. Only works for single image posts and the first image of carousel posts. Not supported for stories or videos. + Tag Instagram users in photos by username and position. Not supported for stories or videos. For carousels, use mediaIndex to target specific slides (defaults to 0). Tags on video items are silently skipped. + """ + audioName: Annotated[str | None, Field(examples=["My Podcast Intro"])] = None + """ + Custom name for original audio in Reels. Replaces the default "Original Audio" label. Can only be set once. + """ + thumbOffset: Annotated[int | None, Field(examples=[5000], ge=0)] = None + """ + Millisecond offset from video start for the Reel thumbnail. Ignored if a custom thumbnail URL is provided. Defaults to 0. """ class LinkedInPlatformData(BaseModel): """ - Constraints: - - Multi-image posts support up to 20 images. - - Multi-video posts are not supported. - - Single PDF document posts are supported. - - Post ID is returned in the x-restli-id response header. - - Link previews are automatically generated for URLs when no media is attached (can be disabled with disableLinkPreview). - + Up to 20 images, no multi-video. Single PDF supported (max 100MB). Link previews auto-generated when no media attached. Use organizationUrn for multi-org posting. """ + documentTitle: str | None = None + """ + Title displayed on LinkedIn document (PDF/carousel) posts. Required by LinkedIn for document posts. If omitted, falls back to the media item title, then the filename. + """ + organizationUrn: str | None = None + """ + Target LinkedIn Organization URN (e.g. "urn:li:organization:123456789"). If omitted, uses the default org. Use GET /v1/accounts/{id}/linkedin-organizations to list orgs. + """ firstComment: str | None = None """ Optional first comment to add after the post is created @@ -250,11 +1282,7 @@ class PinterestPlatformData(BaseModel): class Visibility1(Enum): """ - Video visibility setting: - - public: Anyone can search for and watch (default) - - unlisted: Only people with the link can watch - - private: Only you and people you specifically share with can watch - + Video visibility: public (default, anyone can watch), unlisted (link only), private (invite only) """ PUBLIC = "public" @@ -264,14 +1292,7 @@ class Visibility1(Enum): class YouTubePlatformData(BaseModel): """ - YouTube video upload settings: - - Videos ≤ 3 minutes are automatically detected as YouTube Shorts - - Videos > 3 minutes become regular YouTube videos - - Custom thumbnails supported for regular videos (via mediaItem.thumbnail) - - Custom thumbnails NOT supported for Shorts via API - - Scheduled videos are uploaded immediately as the specified visibility and published at scheduled time - - Visibility defaults to "public" if not specified - + Videos under 3 min auto-detected as Shorts. Custom thumbnails for regular videos only. Scheduled videos are uploaded immediately with the specified visibility. """ title: Annotated[str | None, Field(max_length=100)] = None @@ -280,28 +1301,29 @@ class YouTubePlatformData(BaseModel): """ visibility: Visibility1 = "public" """ - Video visibility setting: - - public: Anyone can search for and watch (default) - - unlisted: Only people with the link can watch - - private: Only you and people you specifically share with can watch - + Video visibility: public (default, anyone can watch), unlisted (link only), private (invite only) + """ + madeForKids: bool = False + """ + COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting). Defaults to false. YouTube may block views if not explicitly set. """ firstComment: Annotated[str | None, Field(max_length=10000)] = None """ Optional first comment to post immediately after video upload. Up to 10,000 characters (YouTube's comment limit). """ + containsSyntheticMedia: bool = False + """ + AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label. + """ + categoryId: str = "22" + """ + YouTube video category ID. Defaults to 22 (People & Blogs). Common: 1 (Film), 2 (Autos), 10 (Music), 15 (Pets), 17 (Sports), 20 (Gaming), 23 (Comedy), 24 (Entertainment), 25 (News), 26 (Howto), 27 (Education), 28 (Science & Tech). + """ class Type1(Enum): """ - Button action type: - - LEARN_MORE: Link to more information - - BOOK: Booking/reservation link - - ORDER: Online ordering link - - SHOP: E-commerce/shopping link - - SIGN_UP: Registration/signup link - - CALL: Phone call action - + Button action type: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL """ LEARN_MORE = "LEARN_MORE" @@ -319,14 +1341,7 @@ class CallToAction(BaseModel): type: Type1 """ - Button action type: - - LEARN_MORE: Link to more information - - BOOK: Booking/reservation link - - ORDER: Online ordering link - - SHOP: E-commerce/shopping link - - SIGN_UP: Registration/signup link - - CALL: Phone call action - + Button action type: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL """ url: AnyUrl """ @@ -336,14 +1351,17 @@ class CallToAction(BaseModel): class GoogleBusinessPlatformData(BaseModel): """ - Google Business Profile post settings: - - Posts support text content and a single image (no videos) - - Images must be publicly accessible URLs - - Call-to-action buttons drive user engagement - - Posts appear on your Google Business Profile and in Google Search/Maps - + Text and single image only (no videos). Optional call-to-action button. Posts appear on GBP, Google Search, and Maps. Use locationId for multi-location posting. """ + locationId: str | None = None + """ + Target GBP location ID (e.g. "locations/123456789"). If omitted, uses the default location. Use GET /v1/accounts/{id}/gmb-locations to list locations. + """ + languageCode: Annotated[str | None, Field(examples=["de"])] = None + """ + BCP 47 language code (e.g. "en", "de", "es"). Auto-detected if omitted. Set explicitly for short or mixed-language posts. + """ callToAction: CallToAction | None = None """ Optional call-to-action button displayed on the post @@ -351,6 +1369,10 @@ class GoogleBusinessPlatformData(BaseModel): class CommercialContentType(Enum): + """ + Type of commercial content disclosure + """ + NONE = "none" BRAND_ORGANIC = "brand_organic" BRAND_CONTENT = "brand_content" @@ -365,69 +1387,169 @@ class MediaType(Enum): PHOTO = "photo" -class TikTokSettings(BaseModel): +class TikTokPlatformData(BaseModel): """ - Required when posting to TikTok. Enforces TikTok Direct Post UX requirements. - - Constraints: - - Photo carousels support up to 35 images. - - **Title length limits**: - - Videos: up to 2200 chars (full content used as title) - - Photos: content is automatically truncated to 90 chars for title (hashtags/URLs stripped). Use 'description' field for longer text (up to 4000 chars). - - privacy_level must be chosen from creator_info.privacy_level_options (no defaulting). - - allow_duet and allow_stitch required for videos; allow_comment for all. - - content_preview_confirmed and express_consent_given must be true before posting. - + Photo carousels up to 35 images. Video titles up to 2200 chars, photo titles truncated to 90 chars. privacyLevel must match creator_info options. Both camelCase and snake_case accepted. """ draft: bool | None = None """ - When true, Late sends the post to the TikTok Creator Inbox as a draft instead of publishing it immediately. When omitted or false, TikTok uses direct posting (live publish) as usual. - + When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing immediately. """ - privacy_level: str | None = None + privacyLevel: str | None = None """ One of the values returned by the TikTok creator info API for the account """ - allow_comment: bool | None = None - allow_duet: bool | None = None + allowComment: bool | None = None + """ + Allow comments on the post + """ + allowDuet: bool | None = None + """ + Allow duets (required for video posts) + """ + allowStitch: bool | None = None + """ + Allow stitches (required for video posts) + """ + commercialContentType: CommercialContentType | None = None """ - Required for video posts + Type of commercial content disclosure """ - allow_stitch: bool | None = None + brandPartnerPromote: bool | None = None """ - Required for video posts + Whether the post promotes a brand partner """ - commercial_content_type: CommercialContentType | None = None - brand_partner_promote: bool | None = None - is_brand_organic_post: bool | None = None - content_preview_confirmed: bool | None = None - express_consent_given: bool | None = None - media_type: MediaType | None = None + isBrandOrganicPost: bool | None = None + """ + Whether the post is a brand organic post + """ + contentPreviewConfirmed: bool | None = None + """ + User has confirmed they previewed the content + """ + expressConsentGiven: bool | None = None + """ + User has given express consent for posting + """ + mediaType: MediaType | None = None """ Optional override. Defaults based on provided media items. """ - video_cover_timestamp_ms: Annotated[int | None, Field(ge=0)] = None + videoCoverTimestampMs: Annotated[int | None, Field(ge=0)] = None """ Optional for video posts. Timestamp in milliseconds to select which frame to use as thumbnail (defaults to 1000ms/1 second). """ - photo_cover_index: Annotated[int | None, Field(ge=0)] = None + photoCoverIndex: Annotated[int | None, Field(ge=0)] = None """ Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). """ - auto_add_music: bool | None = None + autoAddMusic: bool | None = None """ When true, TikTok may add recommended music (photos only) """ - video_made_with_ai: bool | None = None + videoMadeWithAi: bool | None = None """ Set true to disclose AI-generated content """ description: Annotated[str | None, Field(max_length=4000)] = None """ - Optional long-form description for photo posts (max 4000 chars). - Recommended for photo posts when content exceeds 90 characters, as photo titles are automatically truncated to 90 chars (after stripping hashtags/URLs). + Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. + """ + + +class ParseMode(Enum): + """ + Text formatting mode for the message (default is HTML) + """ + + HTML = "HTML" + MARKDOWN = "Markdown" + MARKDOWN_V2 = "MarkdownV2" + + +class TelegramPlatformData(BaseModel): + """ + Text, images (up to 10), videos (up to 10), and mixed media albums. Captions up to 1024 chars for media, 4096 for text-only. + """ + + parseMode: ParseMode | None = None + """ + Text formatting mode for the message (default is HTML) + """ + disableWebPagePreview: bool | None = None + """ + Disable link preview generation for URLs in the message + """ + disableNotification: bool | None = None + """ + Send the message silently (users will receive notification without sound) + """ + protectContent: bool | None = None + """ + Protect message content from forwarding and saving + """ + + +class ContentType2(Enum): + """ + Content type: story (ephemeral 24h, default), saved_story (permanent on Public Profile), spotlight (video feed) + """ + + STORY = "story" + SAVED_STORY = "saved_story" + SPOTLIGHT = "spotlight" + + +class SnapchatPlatformData(BaseModel): + """ + Requires a Public Profile. Single media item only. Content types: story (ephemeral 24h), saved_story (permanent, title max 45 chars), spotlight (video, max 160 chars). + """ + + contentType: ContentType2 = "story" + """ + Content type: story (ephemeral 24h, default), saved_story (permanent on Public Profile), spotlight (video feed) + """ + + +class RedditPlatformData(BaseModel): + """ + Posts are either link (with URL/media) or self (text-only). Use forceSelf to override. Subreddit defaults to the account's configured one. Some subreddits require a flair. + """ + + subreddit: Annotated[str | None, Field(examples=["socialmedia"])] = None + """ + Target subreddit name (without "r/" prefix). Overrides the default. Use GET /v1/accounts/{id}/reddit-subreddits to list options. + """ + title: Annotated[str | None, Field(max_length=300)] = None + """ + Post title. Defaults to the first line of content, truncated to 300 characters. + """ + url: AnyUrl | None = None + """ + URL for link posts. If provided (and forceSelf is not true), creates a link post instead of a text post. + """ + forceSelf: bool | None = None + """ + When true, creates a text/self post even when a URL or media is provided. + """ + flairId: Annotated[ + str | None, Field(examples=["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]) + ] = None + """ + Flair ID for the post. Required by some subreddits. Use GET /v1/accounts/{id}/reddit-flairs?subreddit=name to list flairs. + """ + + +class BlueskyPlatformData(BaseModel): + """ + Bluesky post settings. Supports text posts with up to 4 images or a single video. threadItems creates a reply chain (Bluesky thread). Images exceeding 1MB are automatically compressed. Alt text supported via mediaItem properties. + + """ + threadItems: List[ThreadItem] | None = None + """ + Sequence of posts in a Bluesky thread (root then replies in order). """ @@ -445,10 +1567,18 @@ class QueueSlot(BaseModel): class QueueSchedule(BaseModel): + field_id: Annotated[str | None, Field(alias="_id")] = None + """ + Unique queue identifier + """ profileId: str | None = None """ Profile ID this queue belongs to """ + name: str | None = None + """ + Queue name (e.g., "Morning Posts", "Evening Content") + """ timezone: str | None = None """ IANA timezone (e.g., America/New_York) @@ -456,7 +1586,11 @@ class QueueSchedule(BaseModel): slots: List[QueueSlot] | None = None active: bool | None = None """ - Whether the queue is active + Whether the queue is active + """ + isDefault: bool | None = None + """ + Whether this is the default queue for the profile (used when no queueId specified) """ createdAt: AwareDatetime | None = None updatedAt: AwareDatetime | None = None @@ -476,6 +1610,10 @@ class Profile(BaseModel): description: str | None = None color: str | None = None isDefault: bool | None = None + isOverLimit: bool | None = None + """ + Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. + """ createdAt: AwareDatetime | None = None @@ -485,6 +1623,10 @@ class SocialAccount(BaseModel): profileId: str | Profile | None = None username: str | None = None displayName: str | None = None + profileUrl: str | None = None + """ + Full profile URL for the connected account on its platform. + """ isActive: bool | None = None followersCount: float | None = None """ @@ -496,6 +1638,59 @@ class SocialAccount(BaseModel): """ +class AccountStats(BaseModel): + """ + Platform-specific account stats from the latest daily snapshot. + Fields vary by platform. Only present if metadata has been captured. + + """ + + followingCount: float | None = None + """ + Number of accounts being followed + """ + mediaCount: float | None = None + """ + Total media posts (Instagram) + """ + videoCount: float | None = None + """ + Total videos (YouTube + """ + tweetCount: float | None = None + """ + Total tweets (X/Twitter) + """ + postsCount: float | None = None + """ + Total posts (Bluesky) + """ + pinCount: float | None = None + """ + Total pins (Pinterest) + """ + totalViews: float | None = None + """ + Total channel views (YouTube) + """ + likesCount: float | None = None + """ + Total likes received (TikTok) + """ + monthlyViews: float | None = None + """ + Monthly profile views (Pinterest) + """ + listedCount: float | None = None + """ + Lists the user appears on (X/Twitter) + """ + boardCount: float | None = None + """ + Total boards (Pinterest) + """ + + class AccountWithFollowerStats(SocialAccount): profilePicture: str | None = None currentFollowers: float | None = None @@ -515,6 +1710,36 @@ class AccountWithFollowerStats(SocialAccount): """ Number of historical snapshots """ + accountStats: AccountStats | None = None + """ + Platform-specific account stats from the latest daily snapshot. + Fields vary by platform. Only present if metadata has been captured. + + """ + + +class Scope(Enum): + """ + 'full' grants access to all profiles, 'profiles' restricts to specific profiles + """ + + FULL = "full" + PROFILES = "profiles" + + +class ProfileId(BaseModel): + field_id: Annotated[str | None, Field(alias="_id")] = None + name: str | None = None + color: str | None = None + + +class Permission(Enum): + """ + 'read-write' allows all operations, 'read' restricts to GET requests only + """ + + READ_WRITE = "read-write" + READ = "read" class ApiKey(BaseModel): @@ -527,6 +1752,18 @@ class ApiKey(BaseModel): """ Returned only once, on creation """ + scope: Scope = "full" + """ + 'full' grants access to all profiles, 'profiles' restricts to specific profiles + """ + profileIds: List[ProfileId] | None = None + """ + Profiles this key can access (populated with name and color). Only present when scope is 'profiles'. + """ + permission: Permission = "read-write" + """ + 'read-write' allows all operations, 'read' restricts to GET requests only + """ class BillingPeriod(Enum): @@ -549,6 +1786,10 @@ class UsageStats(BaseModel): planName: str | None = None billingPeriod: BillingPeriod | None = None signupDate: AwareDatetime | None = None + billingAnchorDay: int | None = None + """ + Day of month (1-31) when the billing cycle resets + """ limits: Limits | None = None usage: Usage | None = None @@ -559,31 +1800,73 @@ class PostAnalytics(BaseModel): likes: Annotated[int | None, Field(examples=[0])] = None comments: Annotated[int | None, Field(examples=[0])] = None shares: Annotated[int | None, Field(examples=[0])] = None + saves: Annotated[int | None, Field(examples=[0])] = None + """ + Number of saves/bookmarks (Instagram, Pinterest) + """ clicks: Annotated[int | None, Field(examples=[0])] = None views: Annotated[int | None, Field(examples=[0])] = None engagementRate: Annotated[float | None, Field(examples=[0])] = None lastUpdated: AwareDatetime | None = None -class AccountMetrics(BaseModel): - followers: int | None = None +class Status4(Enum): + PUBLISHED = "published" + FAILED = "failed" + + +class Analytics(BaseModel): + impressions: Annotated[int | None, Field(examples=[0])] = None + reach: Annotated[int | None, Field(examples=[0])] = None + likes: Annotated[int | None, Field(examples=[0])] = None + comments: Annotated[int | None, Field(examples=[0])] = None + shares: Annotated[int | None, Field(examples=[0])] = None + saves: Annotated[int | None, Field(examples=[0])] = None """ - Followers/fans count (e.g., Instagram, Facebook Pages, Twitter) + Number of saves/bookmarks (Instagram, Pinterest) """ - subscribers: int | None = None + clicks: Annotated[int | None, Field(examples=[0])] = None + views: Annotated[int | None, Field(examples=[0])] = None + engagementRate: Annotated[float | None, Field(examples=[0])] = None + lastUpdated: AwareDatetime | None = None + + +class SyncStatus(Enum): """ - Subscribers count (e.g., YouTube) + Sync state of analytics for this platform """ - lastUpdated: AwareDatetime | None = None + + SYNCED = "synced" + PENDING = "pending" + UNAVAILABLE = "unavailable" class PlatformAnalytics(BaseModel): platform: str | None = None - status: str | None = None + status: Status4 | None = None accountId: str | None = None accountUsername: str | None = None - analytics: PostAnalytics | None = None - accountMetrics: AccountMetrics | None = None + analytics: Analytics | None = None + syncStatus: SyncStatus | None = None + """ + Sync state of analytics for this platform + """ + platformPostUrl: AnyUrl | None = None + errorMessage: str | None = None + """ + Error details when status is failed + """ + + +class DataStaleness(BaseModel): + staleAccountCount: int | None = None + """ + Number of accounts with stale analytics data + """ + syncTriggered: bool | None = None + """ + Whether a background sync was triggered for stale accounts + """ class AnalyticsOverview(BaseModel): @@ -591,11 +1874,64 @@ class AnalyticsOverview(BaseModel): publishedPosts: int | None = None scheduledPosts: int | None = None lastSync: AwareDatetime | None = None + dataStaleness: DataStaleness | None = None + + +class Status5(Enum): + """ + Overall post status. "partial" when some platforms published and others failed. + """ + + PUBLISHED = "published" + FAILED = "failed" + PARTIAL = "partial" + + +class SyncStatus1(Enum): + """ + Overall sync state across all platforms + """ + + SYNCED = "synced" + PENDING = "pending" + PARTIAL = "partial" + UNAVAILABLE = "unavailable" + + +class MediaType1(Enum): + IMAGE = "image" + VIDEO = "video" + CAROUSEL = "carousel" + TEXT = "text" + + +class Type2(Enum): + IMAGE = "image" + VIDEO = "video" + + +class MediaItem1(BaseModel): + type: Type2 | None = None + url: AnyUrl | None = None + """ + Direct URL to the media + """ + thumbnail: AnyUrl | None = None + """ + Thumbnail URL (same as url for images) + """ class AnalyticsSinglePostResponse(BaseModel): postId: str | None = None - status: str | None = None + latePostId: str | None = None + """ + Original Late post ID if scheduled via Late + """ + status: Status5 | None = None + """ + Overall post status. "partial" when some platforms published and others failed. + """ content: str | None = None scheduledFor: AwareDatetime | None = None publishedAt: AwareDatetime | None = None @@ -604,17 +1940,49 @@ class AnalyticsSinglePostResponse(BaseModel): platform: str | None = None platformPostUrl: AnyUrl | None = None isExternal: bool | None = None + syncStatus: SyncStatus1 | None = None + """ + Overall sync state across all platforms + """ + message: str | None = None + """ + Human-readable status message for pending, partial, or failed states + """ + thumbnailUrl: AnyUrl | None = None + mediaType: MediaType1 | None = None + mediaItems: List[MediaItem1] | None = None + """ + All media items for this post. Carousel posts contain one entry per slide. + """ -class MediaType1(Enum): +class MediaType2(Enum): IMAGE = "image" VIDEO = "video" GIF = "gif" DOCUMENT = "document" + CAROUSEL = "carousel" + TEXT = "text" -class Post1(BaseModel): +class MediaItem2(BaseModel): + type: Type2 | None = None + url: AnyUrl | None = None + """ + Direct URL to the media + """ + thumbnail: AnyUrl | None = None + """ + Thumbnail URL (same as url for images) + """ + + +class Post3(BaseModel): field_id: Annotated[str | None, Field(alias="_id")] = None + latePostId: str | None = None + """ + Original Late post ID if scheduled via Late + """ content: str | None = None scheduledFor: AwareDatetime | None = None publishedAt: AwareDatetime | None = None @@ -624,14 +1992,18 @@ class Post1(BaseModel): platform: str | None = None platformPostUrl: AnyUrl | None = None isExternal: bool | None = None + profileId: str | None = None thumbnailUrl: AnyUrl | None = None - mediaType: MediaType1 | None = None - mediaItems: List[MediaItem] | None = None + mediaType: MediaType2 | None = None + mediaItems: List[MediaItem2] | None = None + """ + All media items for this post. Carousel posts contain one entry per slide. + """ class AnalyticsListResponse(BaseModel): overview: AnalyticsOverview | None = None - posts: List[Post1] | None = None + posts: List[Post3] | None = None pagination: Pagination | None = None accounts: List[SocialAccount] | None = None """ @@ -643,6 +2015,111 @@ class AnalyticsListResponse(BaseModel): """ +class Aggregation(Enum): + TOTAL = "TOTAL" + + +class Analytics1(BaseModel): + impressions: int | None = None + """ + Total impressions across all posts + """ + reach: int | None = None + """ + Unique members reached across all posts + """ + reactions: int | None = None + """ + Total reactions across all posts + """ + comments: int | None = None + """ + Total comments across all posts + """ + shares: int | None = None + """ + Total reshares across all posts + """ + engagementRate: float | None = None + """ + Overall engagement rate as percentage + """ + + +class LinkedInAggregateAnalyticsTotalResponse(BaseModel): + """ + Response for TOTAL aggregation (lifetime totals) + """ + + accountId: str | None = None + platform: Annotated[str | None, Field(examples=["linkedin"])] = None + accountType: Annotated[str | None, Field(examples=["personal"])] = None + username: str | None = None + aggregation: Aggregation | None = None + dateRange: DateRange | None = None + analytics: Analytics1 | None = None + note: str | None = None + lastUpdated: AwareDatetime | None = None + + +class Aggregation1(Enum): + DAILY = "DAILY" + + +class Impression(BaseModel): + date: date_aliased | None = None + count: int | None = None + + +class Reaction(BaseModel): + date: date_aliased | None = None + count: int | None = None + + +class Comment1(BaseModel): + date: date_aliased | None = None + count: int | None = None + + +class Share(BaseModel): + date: date_aliased | None = None + count: int | None = None + + +class Analytics2(BaseModel): + """ + Daily breakdown of each metric as date/count pairs. Reach not available with DAILY aggregation. + """ + + impressions: List[Impression] | None = None + reactions: List[Reaction] | None = None + comments: List[Comment1] | None = None + shares: List[Share] | None = None + + +class LinkedInAggregateAnalyticsDailyResponse(BaseModel): + """ + Response for DAILY aggregation (time series breakdown) + """ + + accountId: str | None = None + platform: Annotated[str | None, Field(examples=["linkedin"])] = None + accountType: Annotated[str | None, Field(examples=["personal"])] = None + username: str | None = None + aggregation: Aggregation1 | None = None + dateRange: DateRange | None = None + analytics: Analytics2 | None = None + """ + Daily breakdown of each metric as date/count pairs. Reach not available with DAILY aggregation. + """ + skippedMetrics: List[str] | None = None + """ + Metrics that were skipped due to API limitations + """ + note: str | None = None + lastUpdated: AwareDatetime | None = None + + class PostDeleteResponse(BaseModel): message: str | None = None @@ -681,12 +2158,12 @@ class AccountGetResponse(BaseModel): account: SocialAccount | None = None -class DateRange(BaseModel): +class DateRange3(BaseModel): from_: Annotated[AwareDatetime | None, Field(alias="from")] = None to: AwareDatetime | None = None -class Aggregation(Enum): +class Aggregation2(Enum): DAILY = "daily" WEEKLY = "weekly" MONTHLY = "monthly" @@ -694,18 +2171,18 @@ class Aggregation(Enum): class FollowerStatsResponse(BaseModel): accounts: List[AccountWithFollowerStats] | None = None - dateRange: DateRange | None = None - aggregation: Aggregation | None = None + dateRange: DateRange3 | None = None + aggregation: Aggregation2 | None = None -class Type2(Enum): +class Type4(Enum): IMAGE = "image" VIDEO = "video" DOCUMENT = "document" class UploadedFile(BaseModel): - type: Type2 | None = None + type: Type4 | None = None url: AnyUrl | None = None filename: str | None = None size: int | None = None @@ -716,7 +2193,7 @@ class MediaUploadResponse(BaseModel): files: List[UploadedFile] | None = None -class Status1(Enum): +class Status6(Enum): PENDING = "pending" COMPLETED = "completed" EXPIRED = "expired" @@ -726,12 +2203,12 @@ class UploadTokenResponse(BaseModel): token: str | None = None uploadUrl: AnyUrl | None = None expiresAt: AwareDatetime | None = None - status: Status1 | None = None + status: Status6 | None = None class UploadTokenStatusResponse(BaseModel): token: str | None = None - status: Status1 | None = None + status: Status6 | None = None files: List[UploadedFile] | None = None createdAt: AwareDatetime | None = None expiresAt: AwareDatetime | None = None @@ -796,7 +2273,7 @@ class TranscriptResponse(BaseModel): language: str | None = None -class Status3(Enum): +class Status8(Enum): SAFE = "safe" BANNED = "banned" RESTRICTED = "restricted" @@ -805,7 +2282,7 @@ class Status3(Enum): class HashtagInfo(BaseModel): hashtag: str | None = None - status: Status3 | None = None + status: Status8 | None = None postCount: int | None = None @@ -833,22 +2310,16 @@ class UserGetResponse(BaseModel): user: User | None = None -class TikTokPlatformData(BaseModel): - """ - TikTok platform-specific settings. Contains tiktokSettings for video/photo posting options. - - """ - - tiktokSettings: TikTokSettings | None = None - - class PlatformTarget(BaseModel): platform: Annotated[str | None, Field(examples=["twitter"])] = None """ - Supported values: twitter, threads, instagram, youtube, facebook, linkedin, pinterest, reddit, tiktok, bluesky, googlebusiness + Supported values: twitter, threads, instagram, youtube, facebook, linkedin, pinterest, reddit, tiktok, bluesky, googlebusiness, telegram """ accountId: str | SocialAccount | None = None customContent: str | None = None + """ + Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters). + """ customMedia: List[MediaItem] | None = None scheduledFor: AwareDatetime | None = None """ @@ -864,6 +2335,10 @@ class PlatformTarget(BaseModel): | YouTubePlatformData | GoogleBusinessPlatformData | TikTokPlatformData + | TelegramPlatformData + | SnapchatPlatformData + | RedditPlatformData + | BlueskyPlatformData | None ) = None """ @@ -884,19 +2359,27 @@ class PlatformTarget(BaseModel): Field(examples=["https://twitter.com/acmecorp/status/1234567890123456789"]), ] = None """ - Public URL of the published post on the platform. - Populated after successful publish. For immediate posts (publishNow=true), - this is included in the response. For scheduled posts, fetch the post - via GET /v1/posts/{postId} after the scheduled time. - + Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. """ publishedAt: AwareDatetime | None = None """ Timestamp when the post was published to this platform """ + errorMessage: str | None = None + """ + Human-readable error message when status is failed. Contains platform-specific error details explaining why the publish failed. + """ + errorCategory: ErrorCategory | None = None + """ + Error category for programmatic handling: auth_expired (token expired/revoked), user_content (wrong format/too long), user_abuse (rate limits/spam), account_issue (config problems), platform_rejected (policy violation), platform_error (5xx/maintenance), system_error (Zernio infra), unknown + """ + errorSource: ErrorSource | None = None + """ + Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare) + """ -class Post(BaseModel): +class Post2(BaseModel): field_id: Annotated[str | None, Field(alias="_id")] = None userId: str | User | None = None title: str | None = None @@ -909,46 +2392,51 @@ class Post(BaseModel): platforms: List[PlatformTarget] | None = None scheduledFor: AwareDatetime | None = None timezone: str | None = None - status: Status | None = None + status: Status3 | None = None tags: List[str] | None = None """ - YouTube tag constraints when targeting YouTube: - - No count cap; duplicates removed. - - Each tag must be ≤ 100 chars. - - Combined characters across all tags ≤ 500. - + YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. """ hashtags: List[str] | None = None mentions: List[str] | None = None visibility: Visibility | None = None metadata: Dict[str, Any] | None = None + recycling: RecyclingState | None = None + recycledFromPostId: str | None = None + """ + ID of the original post if this post was created via recycling + """ queuedFromProfile: str | None = None """ Profile ID if the post was scheduled via the queue """ + queueId: str | None = None + """ + Queue ID if the post was scheduled via a specific queue + """ createdAt: AwareDatetime | None = None updatedAt: AwareDatetime | None = None class PostsListResponse(BaseModel): - posts: List[Post] | None = None + posts: List[Post2] | None = None pagination: Pagination | None = None class PostGetResponse(BaseModel): - post: Post | None = None + post: Post2 | None = None class PostCreateResponse(BaseModel): message: str | None = None - post: Post | None = None + post: Post2 | None = None class PostUpdateResponse(BaseModel): message: str | None = None - post: Post | None = None + post: Post2 | None = None class PostRetryResponse(BaseModel): message: str | None = None - post: Post | None = None + post: Post2 | None = None diff --git a/src/late/resources/__init__.py b/src/late/resources/__init__.py index 293c6ba..450910f 100644 --- a/src/late/resources/__init__.py +++ b/src/late/resources/__init__.py @@ -1,7 +1,18 @@ """ Late API resources. + +Manual resources with Pydantic validation are used for the main resources. +Auto-generated resources are used for additional endpoints. """ +from ._generated.account_groups import AccountGroupsResource +from ._generated.api_keys import ApiKeysResource +from ._generated.connect import ConnectResource +from ._generated.invites import InvitesResource +from ._generated.logs import LogsResource +from ._generated.reddit import RedditResource +from ._generated.usage import UsageResource +from ._generated.webhooks import WebhooksResource from .accounts import AccountsResource from .analytics import AnalyticsResource from .media import MediaResource @@ -12,12 +23,20 @@ from .users import UsersResource __all__ = [ + "AccountGroupsResource", "AccountsResource", "AnalyticsResource", + "ApiKeysResource", + "ConnectResource", + "InvitesResource", + "LogsResource", "MediaResource", "PostsResource", "ProfilesResource", "QueueResource", + "RedditResource", "ToolsResource", + "UsageResource", "UsersResource", + "WebhooksResource", ] diff --git a/src/late/resources/_generated/__init__.py b/src/late/resources/_generated/__init__.py new file mode 100644 index 0000000..c2cc0a7 --- /dev/null +++ b/src/late/resources/_generated/__init__.py @@ -0,0 +1,55 @@ +"""Auto-generated resources.""" + +from __future__ import annotations + +from .account_groups import AccountGroupsResource +from .account_settings import AccountSettingsResource +from .accounts import AccountsResource +from .analytics import AnalyticsResource +from .api_keys import ApiKeysResource +from .comments import CommentsResource +from .connect import ConnectResource +from .invites import InvitesResource +from .logs import LogsResource +from .media import MediaResource +from .messages import MessagesResource +from .posts import PostsResource +from .profiles import ProfilesResource +from .queue import QueueResource +from .reddit import RedditResource +from .reviews import ReviewsResource +from .tools import ToolsResource +from .twitter_engagement import TwitterEngagementResource +from .usage import UsageResource +from .users import UsersResource +from .validate import ValidateResource +from .webhooks import WebhooksResource +from .whatsapp import WhatsappResource +from .whatsapp_phone_numbers import WhatsappPhoneNumbersResource + +__all__ = [ + "AccountGroupsResource", + "AccountSettingsResource", + "AccountsResource", + "AnalyticsResource", + "ApiKeysResource", + "CommentsResource", + "ConnectResource", + "InvitesResource", + "LogsResource", + "MediaResource", + "MessagesResource", + "PostsResource", + "ProfilesResource", + "QueueResource", + "RedditResource", + "ReviewsResource", + "ToolsResource", + "TwitterEngagementResource", + "UsageResource", + "UsersResource", + "ValidateResource", + "WebhooksResource", + "WhatsappResource", + "WhatsappPhoneNumbersResource", +] diff --git a/src/late/resources/_generated/account_groups.py b/src/late/resources/_generated/account_groups.py new file mode 100644 index 0000000..6af52f6 --- /dev/null +++ b/src/late/resources/_generated/account_groups.py @@ -0,0 +1,111 @@ +""" +Auto-generated account_groups resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class AccountGroupsResource: + """ + Organize accounts into groups. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_account_groups(self) -> dict[str, Any]: + """List groups""" + return self._client._get("/v1/account-groups") + + def create_account_group(self, name: str, account_ids: list[str]) -> dict[str, Any]: + """Create group""" + payload = self._build_payload( + name=name, + account_ids=account_ids, + ) + return self._client._post("/v1/account-groups", data=payload) + + def update_account_group( + self, + group_id: str, + *, + name: str | None = None, + account_ids: list[str] | None = None, + ) -> dict[str, Any]: + """Update group""" + payload = self._build_payload( + name=name, + account_ids=account_ids, + ) + return self._client._put(f"/v1/account-groups/{group_id}", data=payload) + + def delete_account_group(self, group_id: str) -> dict[str, Any]: + """Delete group""" + return self._client._delete(f"/v1/account-groups/{group_id}") + + async def alist_account_groups(self) -> dict[str, Any]: + """List groups (async)""" + return await self._client._aget("/v1/account-groups") + + async def acreate_account_group( + self, name: str, account_ids: list[str] + ) -> dict[str, Any]: + """Create group (async)""" + payload = self._build_payload( + name=name, + account_ids=account_ids, + ) + return await self._client._apost("/v1/account-groups", data=payload) + + async def aupdate_account_group( + self, + group_id: str, + *, + name: str | None = None, + account_ids: list[str] | None = None, + ) -> dict[str, Any]: + """Update group (async)""" + payload = self._build_payload( + name=name, + account_ids=account_ids, + ) + return await self._client._aput(f"/v1/account-groups/{group_id}", data=payload) + + async def adelete_account_group(self, group_id: str) -> dict[str, Any]: + """Delete group (async)""" + return await self._client._adelete(f"/v1/account-groups/{group_id}") diff --git a/src/late/resources/_generated/account_settings.py b/src/late/resources/_generated/account_settings.py new file mode 100644 index 0000000..7db320a --- /dev/null +++ b/src/late/resources/_generated/account_settings.py @@ -0,0 +1,169 @@ +""" +Auto-generated account_settings resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class AccountSettingsResource: + """ + account_settings operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_messenger_menu(self, account_id: str) -> dict[str, Any]: + """Get FB persistent menu""" + return self._client._get(f"/v1/accounts/{account_id}/messenger-menu") + + def set_messenger_menu( + self, account_id: str, persistent_menu: list[dict[str, Any]] + ) -> dict[str, Any]: + """Set FB persistent menu""" + payload = self._build_payload( + persistent_menu=persistent_menu, + ) + return self._client._put( + f"/v1/accounts/{account_id}/messenger-menu", data=payload + ) + + def delete_messenger_menu(self, account_id: str) -> dict[str, Any]: + """Delete FB persistent menu""" + return self._client._delete(f"/v1/accounts/{account_id}/messenger-menu") + + def get_instagram_ice_breakers(self, account_id: str) -> dict[str, Any]: + """Get IG ice breakers""" + return self._client._get(f"/v1/accounts/{account_id}/instagram-ice-breakers") + + def set_instagram_ice_breakers( + self, account_id: str, ice_breakers: list[dict[str, Any]] + ) -> dict[str, Any]: + """Set IG ice breakers""" + payload = self._build_payload( + ice_breakers=ice_breakers, + ) + return self._client._put( + f"/v1/accounts/{account_id}/instagram-ice-breakers", data=payload + ) + + def delete_instagram_ice_breakers(self, account_id: str) -> dict[str, Any]: + """Delete IG ice breakers""" + return self._client._delete(f"/v1/accounts/{account_id}/instagram-ice-breakers") + + def get_telegram_commands(self, account_id: str) -> dict[str, Any]: + """Get TG bot commands""" + return self._client._get(f"/v1/accounts/{account_id}/telegram-commands") + + def set_telegram_commands( + self, account_id: str, commands: list[dict[str, Any]] + ) -> dict[str, Any]: + """Set TG bot commands""" + payload = self._build_payload( + commands=commands, + ) + return self._client._put( + f"/v1/accounts/{account_id}/telegram-commands", data=payload + ) + + def delete_telegram_commands(self, account_id: str) -> dict[str, Any]: + """Delete TG bot commands""" + return self._client._delete(f"/v1/accounts/{account_id}/telegram-commands") + + async def aget_messenger_menu(self, account_id: str) -> dict[str, Any]: + """Get FB persistent menu (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/messenger-menu") + + async def aset_messenger_menu( + self, account_id: str, persistent_menu: list[dict[str, Any]] + ) -> dict[str, Any]: + """Set FB persistent menu (async)""" + payload = self._build_payload( + persistent_menu=persistent_menu, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/messenger-menu", data=payload + ) + + async def adelete_messenger_menu(self, account_id: str) -> dict[str, Any]: + """Delete FB persistent menu (async)""" + return await self._client._adelete(f"/v1/accounts/{account_id}/messenger-menu") + + async def aget_instagram_ice_breakers(self, account_id: str) -> dict[str, Any]: + """Get IG ice breakers (async)""" + return await self._client._aget( + f"/v1/accounts/{account_id}/instagram-ice-breakers" + ) + + async def aset_instagram_ice_breakers( + self, account_id: str, ice_breakers: list[dict[str, Any]] + ) -> dict[str, Any]: + """Set IG ice breakers (async)""" + payload = self._build_payload( + ice_breakers=ice_breakers, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/instagram-ice-breakers", data=payload + ) + + async def adelete_instagram_ice_breakers(self, account_id: str) -> dict[str, Any]: + """Delete IG ice breakers (async)""" + return await self._client._adelete( + f"/v1/accounts/{account_id}/instagram-ice-breakers" + ) + + async def aget_telegram_commands(self, account_id: str) -> dict[str, Any]: + """Get TG bot commands (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/telegram-commands") + + async def aset_telegram_commands( + self, account_id: str, commands: list[dict[str, Any]] + ) -> dict[str, Any]: + """Set TG bot commands (async)""" + payload = self._build_payload( + commands=commands, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/telegram-commands", data=payload + ) + + async def adelete_telegram_commands(self, account_id: str) -> dict[str, Any]: + """Delete TG bot commands (async)""" + return await self._client._adelete( + f"/v1/accounts/{account_id}/telegram-commands" + ) diff --git a/src/late/resources/_generated/accounts.py b/src/late/resources/_generated/accounts.py new file mode 100644 index 0000000..bd4db48 --- /dev/null +++ b/src/late/resources/_generated/accounts.py @@ -0,0 +1,645 @@ +""" +Auto-generated accounts resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class AccountsResource: + """ + Manage connected social media accounts. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_accounts( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + include_over_limit: bool | None = False, + ) -> dict[str, Any]: + """List accounts""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + include_over_limit=include_over_limit, + ) + return self._client._get("/v1/accounts", params=params) + + def get_follower_stats( + self, + *, + account_ids: str | None = None, + profile_id: str | None = None, + from_date: str | None = None, + to_date: str | None = None, + granularity: str | None = "daily", + ) -> dict[str, Any]: + """Get follower stats""" + params = self._build_params( + account_ids=account_ids, + profile_id=profile_id, + from_date=from_date, + to_date=to_date, + granularity=granularity, + ) + return self._client._get("/v1/accounts/follower-stats", params=params) + + def update_account( + self, + account_id: str, + *, + username: str | None = None, + display_name: str | None = None, + ) -> dict[str, Any]: + """Update account""" + payload = self._build_payload( + username=username, + display_name=display_name, + ) + return self._client._put(f"/v1/accounts/{account_id}", data=payload) + + def delete_account(self, account_id: str) -> dict[str, Any]: + """Disconnect account""" + return self._client._delete(f"/v1/accounts/{account_id}") + + def get_all_accounts_health( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + status: str | None = None, + ) -> dict[str, Any]: + """Check accounts health""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + status=status, + ) + return self._client._get("/v1/accounts/health", params=params) + + def get_account_health(self, account_id: str) -> dict[str, Any]: + """Check account health""" + return self._client._get(f"/v1/accounts/{account_id}/health") + + def get_google_business_reviews( + self, + account_id: str, + *, + location_id: str | None = None, + page_size: int | None = 50, + page_token: str | None = None, + ) -> dict[str, Any]: + """Get reviews""" + params = self._build_params( + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-reviews", params=params + ) + + def get_google_business_food_menus( + self, account_id: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Get food menus""" + params = self._build_params( + location_id=location_id, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-food-menus", params=params + ) + + def update_google_business_food_menus( + self, + account_id: str, + menus: list[Any], + *, + location_id: str | None = None, + update_mask: str | None = None, + ) -> dict[str, Any]: + """Update food menus""" + payload = self._build_payload( + menus=menus, + update_mask=update_mask, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-food-menus", data=payload + ) + + def get_google_business_location_details( + self, + account_id: str, + *, + location_id: str | None = None, + read_mask: str | None = None, + ) -> dict[str, Any]: + """Get location details""" + params = self._build_params( + location_id=location_id, + read_mask=read_mask, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-location-details", params=params + ) + + def update_google_business_location_details( + self, + account_id: str, + update_mask: str, + *, + location_id: str | None = None, + regular_hours: dict[str, Any] | None = None, + special_hours: dict[str, Any] | None = None, + profile: dict[str, Any] | None = None, + website_uri: str | None = None, + phone_numbers: dict[str, Any] | None = None, + categories: dict[str, Any] | None = None, + service_items: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Update location details""" + payload = self._build_payload( + update_mask=update_mask, + regular_hours=regular_hours, + special_hours=special_hours, + profile=profile, + website_uri=website_uri, + phone_numbers=phone_numbers, + categories=categories, + service_items=service_items, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-location-details", data=payload + ) + + def list_google_business_media( + self, + account_id: str, + *, + location_id: str | None = None, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List media""" + params = self._build_params( + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return self._client._get(f"/v1/accounts/{account_id}/gmb-media", params=params) + + def create_google_business_media( + self, + account_id: str, + source_url: str, + *, + location_id: str | None = None, + media_format: str | None = "PHOTO", + description: str | None = None, + category: str | None = None, + ) -> dict[str, Any]: + """Upload photo""" + payload = self._build_payload( + source_url=source_url, + media_format=media_format, + description=description, + category=category, + ) + return self._client._post(f"/v1/accounts/{account_id}/gmb-media", data=payload) + + def delete_google_business_media( + self, account_id: str, media_id: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Delete photo""" + params = self._build_params( + location_id=location_id, + media_id=media_id, + ) + return self._client._delete( + f"/v1/accounts/{account_id}/gmb-media", params=params + ) + + def get_google_business_attributes( + self, account_id: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Get attributes""" + params = self._build_params( + location_id=location_id, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-attributes", params=params + ) + + def update_google_business_attributes( + self, + account_id: str, + attributes: list[dict[str, Any]], + attribute_mask: str, + *, + location_id: str | None = None, + ) -> dict[str, Any]: + """Update attributes""" + payload = self._build_payload( + attributes=attributes, + attribute_mask=attribute_mask, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-attributes", data=payload + ) + + def list_google_business_place_actions( + self, + account_id: str, + *, + location_id: str | None = None, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List action links""" + params = self._build_params( + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + def create_google_business_place_action( + self, + account_id: str, + uri: str, + place_action_type: str, + *, + location_id: str | None = None, + ) -> dict[str, Any]: + """Create action link""" + payload = self._build_payload( + uri=uri, + place_action_type=place_action_type, + ) + return self._client._post( + f"/v1/accounts/{account_id}/gmb-place-actions", data=payload + ) + + def delete_google_business_place_action( + self, account_id: str, name: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Delete action link""" + params = self._build_params( + location_id=location_id, + name=name, + ) + return self._client._delete( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + def get_linked_in_mentions( + self, account_id: str, url: str, *, display_name: str | None = None + ) -> dict[str, Any]: + """Resolve LinkedIn mention""" + params = self._build_params( + url=url, + display_name=display_name, + ) + return self._client._get( + f"/v1/accounts/{account_id}/linkedin-mentions", params=params + ) + + async def alist_accounts( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + include_over_limit: bool | None = False, + ) -> dict[str, Any]: + """List accounts (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + include_over_limit=include_over_limit, + ) + return await self._client._aget("/v1/accounts", params=params) + + async def aget_follower_stats( + self, + *, + account_ids: str | None = None, + profile_id: str | None = None, + from_date: str | None = None, + to_date: str | None = None, + granularity: str | None = "daily", + ) -> dict[str, Any]: + """Get follower stats (async)""" + params = self._build_params( + account_ids=account_ids, + profile_id=profile_id, + from_date=from_date, + to_date=to_date, + granularity=granularity, + ) + return await self._client._aget("/v1/accounts/follower-stats", params=params) + + async def aupdate_account( + self, + account_id: str, + *, + username: str | None = None, + display_name: str | None = None, + ) -> dict[str, Any]: + """Update account (async)""" + payload = self._build_payload( + username=username, + display_name=display_name, + ) + return await self._client._aput(f"/v1/accounts/{account_id}", data=payload) + + async def adelete_account(self, account_id: str) -> dict[str, Any]: + """Disconnect account (async)""" + return await self._client._adelete(f"/v1/accounts/{account_id}") + + async def aget_all_accounts_health( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + status: str | None = None, + ) -> dict[str, Any]: + """Check accounts health (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + status=status, + ) + return await self._client._aget("/v1/accounts/health", params=params) + + async def aget_account_health(self, account_id: str) -> dict[str, Any]: + """Check account health (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/health") + + async def aget_google_business_reviews( + self, + account_id: str, + *, + location_id: str | None = None, + page_size: int | None = 50, + page_token: str | None = None, + ) -> dict[str, Any]: + """Get reviews (async)""" + params = self._build_params( + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-reviews", params=params + ) + + async def aget_google_business_food_menus( + self, account_id: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Get food menus (async)""" + params = self._build_params( + location_id=location_id, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-food-menus", params=params + ) + + async def aupdate_google_business_food_menus( + self, + account_id: str, + menus: list[Any], + *, + location_id: str | None = None, + update_mask: str | None = None, + ) -> dict[str, Any]: + """Update food menus (async)""" + payload = self._build_payload( + menus=menus, + update_mask=update_mask, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-food-menus", data=payload + ) + + async def aget_google_business_location_details( + self, + account_id: str, + *, + location_id: str | None = None, + read_mask: str | None = None, + ) -> dict[str, Any]: + """Get location details (async)""" + params = self._build_params( + location_id=location_id, + read_mask=read_mask, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-location-details", params=params + ) + + async def aupdate_google_business_location_details( + self, + account_id: str, + update_mask: str, + *, + location_id: str | None = None, + regular_hours: dict[str, Any] | None = None, + special_hours: dict[str, Any] | None = None, + profile: dict[str, Any] | None = None, + website_uri: str | None = None, + phone_numbers: dict[str, Any] | None = None, + categories: dict[str, Any] | None = None, + service_items: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Update location details (async)""" + payload = self._build_payload( + update_mask=update_mask, + regular_hours=regular_hours, + special_hours=special_hours, + profile=profile, + website_uri=website_uri, + phone_numbers=phone_numbers, + categories=categories, + service_items=service_items, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-location-details", data=payload + ) + + async def alist_google_business_media( + self, + account_id: str, + *, + location_id: str | None = None, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List media (async)""" + params = self._build_params( + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-media", params=params + ) + + async def acreate_google_business_media( + self, + account_id: str, + source_url: str, + *, + location_id: str | None = None, + media_format: str | None = "PHOTO", + description: str | None = None, + category: str | None = None, + ) -> dict[str, Any]: + """Upload photo (async)""" + payload = self._build_payload( + source_url=source_url, + media_format=media_format, + description=description, + category=category, + ) + return await self._client._apost( + f"/v1/accounts/{account_id}/gmb-media", data=payload + ) + + async def adelete_google_business_media( + self, account_id: str, media_id: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Delete photo (async)""" + params = self._build_params( + location_id=location_id, + media_id=media_id, + ) + return await self._client._adelete( + f"/v1/accounts/{account_id}/gmb-media", params=params + ) + + async def aget_google_business_attributes( + self, account_id: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Get attributes (async)""" + params = self._build_params( + location_id=location_id, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-attributes", params=params + ) + + async def aupdate_google_business_attributes( + self, + account_id: str, + attributes: list[dict[str, Any]], + attribute_mask: str, + *, + location_id: str | None = None, + ) -> dict[str, Any]: + """Update attributes (async)""" + payload = self._build_payload( + attributes=attributes, + attribute_mask=attribute_mask, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-attributes", data=payload + ) + + async def alist_google_business_place_actions( + self, + account_id: str, + *, + location_id: str | None = None, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List action links (async)""" + params = self._build_params( + location_id=location_id, + page_size=page_size, + page_token=page_token, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + async def acreate_google_business_place_action( + self, + account_id: str, + uri: str, + place_action_type: str, + *, + location_id: str | None = None, + ) -> dict[str, Any]: + """Create action link (async)""" + payload = self._build_payload( + uri=uri, + place_action_type=place_action_type, + ) + return await self._client._apost( + f"/v1/accounts/{account_id}/gmb-place-actions", data=payload + ) + + async def adelete_google_business_place_action( + self, account_id: str, name: str, *, location_id: str | None = None + ) -> dict[str, Any]: + """Delete action link (async)""" + params = self._build_params( + location_id=location_id, + name=name, + ) + return await self._client._adelete( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + async def aget_linked_in_mentions( + self, account_id: str, url: str, *, display_name: str | None = None + ) -> dict[str, Any]: + """Resolve LinkedIn mention (async)""" + params = self._build_params( + url=url, + display_name=display_name, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/linkedin-mentions", params=params + ) diff --git a/src/late/resources/_generated/analytics.py b/src/late/resources/_generated/analytics.py new file mode 100644 index 0000000..d29934f --- /dev/null +++ b/src/late/resources/_generated/analytics.py @@ -0,0 +1,401 @@ +""" +Auto-generated analytics resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datetime import datetime + + from ..client.base import BaseClient + + +class AnalyticsResource: + """ + Get performance metrics and analytics. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_analytics( + self, + *, + post_id: str | None = None, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + from_date: str | None = None, + to_date: str | None = None, + limit: int | None = 50, + page: int | None = 1, + sort_by: str | None = "date", + order: str | None = "desc", + ) -> dict[str, Any]: + """Get post analytics""" + params = self._build_params( + post_id=post_id, + platform=platform, + profile_id=profile_id, + source=source, + from_date=from_date, + to_date=to_date, + limit=limit, + page=page, + sort_by=sort_by, + order=order, + ) + return self._client._get("/v1/analytics", params=params) + + def get_you_tube_daily_views( + self, + video_id: str, + account_id: str, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> dict[str, Any]: + """Get YouTube daily views""" + params = self._build_params( + video_id=video_id, + account_id=account_id, + start_date=start_date, + end_date=end_date, + ) + return self._client._get("/v1/analytics/youtube/daily-views", params=params) + + def get_daily_metrics( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + from_date: datetime | str | None = None, + to_date: datetime | str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get daily aggregated metrics""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + from_date=from_date, + to_date=to_date, + source=source, + ) + return self._client._get("/v1/analytics/daily-metrics", params=params) + + def get_best_time_to_post( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get best times to post""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + source=source, + ) + return self._client._get("/v1/analytics/best-time", params=params) + + def get_content_decay( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get content performance decay""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + source=source, + ) + return self._client._get("/v1/analytics/content-decay", params=params) + + def get_posting_frequency( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get posting frequency vs engagement""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + source=source, + ) + return self._client._get("/v1/analytics/posting-frequency", params=params) + + def get_post_timeline( + self, + post_id: str, + *, + from_date: datetime | str | None = None, + to_date: datetime | str | None = None, + ) -> dict[str, Any]: + """Get post analytics timeline""" + params = self._build_params( + post_id=post_id, + from_date=from_date, + to_date=to_date, + ) + return self._client._get("/v1/analytics/post-timeline", params=params) + + def get_linked_in_aggregate_analytics( + self, + account_id: str, + *, + aggregation: str | None = "TOTAL", + start_date: str | None = None, + end_date: str | None = None, + metrics: str | None = None, + ) -> dict[str, Any]: + """Get LinkedIn aggregate stats""" + params = self._build_params( + aggregation=aggregation, + start_date=start_date, + end_date=end_date, + metrics=metrics, + ) + return self._client._get( + f"/v1/accounts/{account_id}/linkedin-aggregate-analytics", params=params + ) + + def get_linked_in_post_analytics(self, account_id: str, urn: str) -> dict[str, Any]: + """Get LinkedIn post stats""" + params = self._build_params( + urn=urn, + ) + return self._client._get( + f"/v1/accounts/{account_id}/linkedin-post-analytics", params=params + ) + + def get_linked_in_post_reactions( + self, + account_id: str, + urn: str, + *, + limit: int | None = 25, + cursor: str | None = None, + ) -> dict[str, Any]: + """Get LinkedIn post reactions""" + params = self._build_params( + urn=urn, + limit=limit, + cursor=cursor, + ) + return self._client._get( + f"/v1/accounts/{account_id}/linkedin-post-reactions", params=params + ) + + async def aget_analytics( + self, + *, + post_id: str | None = None, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + from_date: str | None = None, + to_date: str | None = None, + limit: int | None = 50, + page: int | None = 1, + sort_by: str | None = "date", + order: str | None = "desc", + ) -> dict[str, Any]: + """Get post analytics (async)""" + params = self._build_params( + post_id=post_id, + platform=platform, + profile_id=profile_id, + source=source, + from_date=from_date, + to_date=to_date, + limit=limit, + page=page, + sort_by=sort_by, + order=order, + ) + return await self._client._aget("/v1/analytics", params=params) + + async def aget_you_tube_daily_views( + self, + video_id: str, + account_id: str, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> dict[str, Any]: + """Get YouTube daily views (async)""" + params = self._build_params( + video_id=video_id, + account_id=account_id, + start_date=start_date, + end_date=end_date, + ) + return await self._client._aget( + "/v1/analytics/youtube/daily-views", params=params + ) + + async def aget_daily_metrics( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + from_date: datetime | str | None = None, + to_date: datetime | str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get daily aggregated metrics (async)""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + from_date=from_date, + to_date=to_date, + source=source, + ) + return await self._client._aget("/v1/analytics/daily-metrics", params=params) + + async def aget_best_time_to_post( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get best times to post (async)""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + source=source, + ) + return await self._client._aget("/v1/analytics/best-time", params=params) + + async def aget_content_decay( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get content performance decay (async)""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + source=source, + ) + return await self._client._aget("/v1/analytics/content-decay", params=params) + + async def aget_posting_frequency( + self, + *, + platform: str | None = None, + profile_id: str | None = None, + source: str | None = "all", + ) -> dict[str, Any]: + """Get posting frequency vs engagement (async)""" + params = self._build_params( + platform=platform, + profile_id=profile_id, + source=source, + ) + return await self._client._aget( + "/v1/analytics/posting-frequency", params=params + ) + + async def aget_post_timeline( + self, + post_id: str, + *, + from_date: datetime | str | None = None, + to_date: datetime | str | None = None, + ) -> dict[str, Any]: + """Get post analytics timeline (async)""" + params = self._build_params( + post_id=post_id, + from_date=from_date, + to_date=to_date, + ) + return await self._client._aget("/v1/analytics/post-timeline", params=params) + + async def aget_linked_in_aggregate_analytics( + self, + account_id: str, + *, + aggregation: str | None = "TOTAL", + start_date: str | None = None, + end_date: str | None = None, + metrics: str | None = None, + ) -> dict[str, Any]: + """Get LinkedIn aggregate stats (async)""" + params = self._build_params( + aggregation=aggregation, + start_date=start_date, + end_date=end_date, + metrics=metrics, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/linkedin-aggregate-analytics", params=params + ) + + async def aget_linked_in_post_analytics( + self, account_id: str, urn: str + ) -> dict[str, Any]: + """Get LinkedIn post stats (async)""" + params = self._build_params( + urn=urn, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/linkedin-post-analytics", params=params + ) + + async def aget_linked_in_post_reactions( + self, + account_id: str, + urn: str, + *, + limit: int | None = 25, + cursor: str | None = None, + ) -> dict[str, Any]: + """Get LinkedIn post reactions (async)""" + params = self._build_params( + urn=urn, + limit=limit, + cursor=cursor, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/linkedin-post-reactions", params=params + ) diff --git a/src/late/resources/_generated/api_keys.py b/src/late/resources/_generated/api_keys.py new file mode 100644 index 0000000..74edfe8 --- /dev/null +++ b/src/late/resources/_generated/api_keys.py @@ -0,0 +1,103 @@ +""" +Auto-generated api_keys resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class ApiKeysResource: + """ + Manage API keys. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_api_keys(self) -> dict[str, Any]: + """List keys""" + return self._client._get("/v1/api-keys") + + def create_api_key( + self, + name: str, + *, + expires_in: int | None = None, + scope: str | None = "full", + profile_ids: list[str] | None = None, + permission: str | None = "read-write", + ) -> dict[str, Any]: + """Create key""" + payload = self._build_payload( + name=name, + expires_in=expires_in, + scope=scope, + profile_ids=profile_ids, + permission=permission, + ) + return self._client._post("/v1/api-keys", data=payload) + + def delete_api_key(self, key_id: str) -> dict[str, Any]: + """Delete key""" + return self._client._delete(f"/v1/api-keys/{key_id}") + + async def alist_api_keys(self) -> dict[str, Any]: + """List keys (async)""" + return await self._client._aget("/v1/api-keys") + + async def acreate_api_key( + self, + name: str, + *, + expires_in: int | None = None, + scope: str | None = "full", + profile_ids: list[str] | None = None, + permission: str | None = "read-write", + ) -> dict[str, Any]: + """Create key (async)""" + payload = self._build_payload( + name=name, + expires_in=expires_in, + scope=scope, + profile_ids=profile_ids, + permission=permission, + ) + return await self._client._apost("/v1/api-keys", data=payload) + + async def adelete_api_key(self, key_id: str) -> dict[str, Any]: + """Delete key (async)""" + return await self._client._adelete(f"/v1/api-keys/{key_id}") diff --git a/src/late/resources/_generated/comments.py b/src/late/resources/_generated/comments.py new file mode 100644 index 0000000..14a8cc3 --- /dev/null +++ b/src/late/resources/_generated/comments.py @@ -0,0 +1,337 @@ +""" +Auto-generated comments resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datetime import datetime + + from ..client.base import BaseClient + + +class CommentsResource: + """ + comments operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_inbox_comments( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_comments: int | None = None, + since: datetime | str | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List commented posts""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_comments=min_comments, + since=since, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return self._client._get("/v1/inbox/comments", params=params) + + def get_inbox_post_comments( + self, + post_id: str, + account_id: str, + *, + subreddit: str | None = None, + limit: int | None = 25, + cursor: str | None = None, + comment_id: str | None = None, + ) -> dict[str, Any]: + """Get post comments""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + limit=limit, + cursor=cursor, + comment_id=comment_id, + ) + return self._client._get(f"/v1/inbox/comments/{post_id}", params=params) + + def reply_to_inbox_post( + self, + post_id: str, + account_id: str, + message: str, + *, + comment_id: str | None = None, + parent_cid: str | None = None, + root_uri: str | None = None, + root_cid: str | None = None, + ) -> dict[str, Any]: + """Reply to comment""" + payload = self._build_payload( + account_id=account_id, + message=message, + comment_id=comment_id, + parent_cid=parent_cid, + root_uri=root_uri, + root_cid=root_cid, + ) + return self._client._post(f"/v1/inbox/comments/{post_id}", data=payload) + + def delete_inbox_comment( + self, post_id: str, account_id: str, comment_id: str + ) -> dict[str, Any]: + """Delete comment""" + params = self._build_params( + account_id=account_id, + comment_id=comment_id, + ) + return self._client._delete(f"/v1/inbox/comments/{post_id}", params=params) + + def hide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Hide comment""" + payload = self._build_payload( + account_id=account_id, + ) + return self._client._post( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", data=payload + ) + + def unhide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Unhide comment""" + params = self._build_params( + account_id=account_id, + ) + return self._client._delete( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", params=params + ) + + def like_inbox_comment( + self, post_id: str, comment_id: str, account_id: str, *, cid: str | None = None + ) -> dict[str, Any]: + """Like comment""" + payload = self._build_payload( + account_id=account_id, + cid=cid, + ) + return self._client._post( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", data=payload + ) + + def unlike_inbox_comment( + self, + post_id: str, + comment_id: str, + account_id: str, + *, + like_uri: str | None = None, + ) -> dict[str, Any]: + """Unlike comment""" + params = self._build_params( + account_id=account_id, + like_uri=like_uri, + ) + return self._client._delete( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", params=params + ) + + def send_private_reply_to_comment( + self, post_id: str, comment_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Send private reply""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return self._client._post( + f"/v1/inbox/comments/{post_id}/{comment_id}/private-reply", data=payload + ) + + async def alist_inbox_comments( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_comments: int | None = None, + since: datetime | str | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List commented posts (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_comments=min_comments, + since=since, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return await self._client._aget("/v1/inbox/comments", params=params) + + async def aget_inbox_post_comments( + self, + post_id: str, + account_id: str, + *, + subreddit: str | None = None, + limit: int | None = 25, + cursor: str | None = None, + comment_id: str | None = None, + ) -> dict[str, Any]: + """Get post comments (async)""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + limit=limit, + cursor=cursor, + comment_id=comment_id, + ) + return await self._client._aget(f"/v1/inbox/comments/{post_id}", params=params) + + async def areply_to_inbox_post( + self, + post_id: str, + account_id: str, + message: str, + *, + comment_id: str | None = None, + parent_cid: str | None = None, + root_uri: str | None = None, + root_cid: str | None = None, + ) -> dict[str, Any]: + """Reply to comment (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + comment_id=comment_id, + parent_cid=parent_cid, + root_uri=root_uri, + root_cid=root_cid, + ) + return await self._client._apost(f"/v1/inbox/comments/{post_id}", data=payload) + + async def adelete_inbox_comment( + self, post_id: str, account_id: str, comment_id: str + ) -> dict[str, Any]: + """Delete comment (async)""" + params = self._build_params( + account_id=account_id, + comment_id=comment_id, + ) + return await self._client._adelete( + f"/v1/inbox/comments/{post_id}", params=params + ) + + async def ahide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Hide comment (async)""" + payload = self._build_payload( + account_id=account_id, + ) + return await self._client._apost( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", data=payload + ) + + async def aunhide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Unhide comment (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._adelete( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", params=params + ) + + async def alike_inbox_comment( + self, post_id: str, comment_id: str, account_id: str, *, cid: str | None = None + ) -> dict[str, Any]: + """Like comment (async)""" + payload = self._build_payload( + account_id=account_id, + cid=cid, + ) + return await self._client._apost( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", data=payload + ) + + async def aunlike_inbox_comment( + self, + post_id: str, + comment_id: str, + account_id: str, + *, + like_uri: str | None = None, + ) -> dict[str, Any]: + """Unlike comment (async)""" + params = self._build_params( + account_id=account_id, + like_uri=like_uri, + ) + return await self._client._adelete( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", params=params + ) + + async def asend_private_reply_to_comment( + self, post_id: str, comment_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Send private reply (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return await self._client._apost( + f"/v1/inbox/comments/{post_id}/{comment_id}/private-reply", data=payload + ) diff --git a/src/late/resources/_generated/connect.py b/src/late/resources/_generated/connect.py new file mode 100644 index 0000000..1e8b8fc --- /dev/null +++ b/src/late/resources/_generated/connect.py @@ -0,0 +1,757 @@ +""" +Auto-generated connect resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class ConnectResource: + """ + OAuth connection flows. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_connect_url( + self, + platform: str, + profile_id: str, + *, + redirect_url: str | None = None, + headless: bool | None = False, + ) -> dict[str, Any]: + """Get OAuth connect URL""" + params = self._build_params( + profile_id=profile_id, + redirect_url=redirect_url, + headless=headless, + ) + return self._client._get(f"/v1/connect/{platform}", params=params) + + def handle_o_auth_callback( + self, platform: str, code: str, state: str, profile_id: str + ) -> dict[str, Any]: + """Complete OAuth callback""" + payload = self._build_payload( + code=code, + state=state, + profile_id=profile_id, + ) + return self._client._post(f"/v1/connect/{platform}", data=payload) + + def list_facebook_pages(self, profile_id: str, temp_token: str) -> dict[str, Any]: + """List Facebook pages""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return self._client._get("/v1/connect/facebook/select-page", params=params) + + def select_facebook_page( + self, + profile_id: str, + page_id: str, + temp_token: str, + *, + user_profile: dict[str, Any] | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select Facebook page""" + payload = self._build_payload( + profile_id=profile_id, + page_id=page_id, + temp_token=temp_token, + user_profile=user_profile, + redirect_url=redirect_url, + ) + return self._client._post("/v1/connect/facebook/select-page", data=payload) + + def list_google_business_locations( + self, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List GBP locations""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return self._client._get("/v1/connect/googlebusiness/locations", params=params) + + def select_google_business_location( + self, + profile_id: str, + location_id: str, + temp_token: str, + *, + user_profile: dict[str, Any] | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select GBP location""" + payload = self._build_payload( + profile_id=profile_id, + location_id=location_id, + temp_token=temp_token, + user_profile=user_profile, + redirect_url=redirect_url, + ) + return self._client._post( + "/v1/connect/googlebusiness/select-location", data=payload + ) + + def get_pending_o_auth_data(self, token: str) -> dict[str, Any]: + """Get pending OAuth data""" + params = self._build_params( + token=token, + ) + return self._client._get("/v1/connect/pending-data", params=params) + + def list_linked_in_organizations( + self, temp_token: str, org_ids: str + ) -> dict[str, Any]: + """List LinkedIn orgs""" + params = self._build_params( + temp_token=temp_token, + org_ids=org_ids, + ) + return self._client._get("/v1/connect/linkedin/organizations", params=params) + + def select_linked_in_organization( + self, + profile_id: str, + temp_token: str, + user_profile: dict[str, Any], + account_type: str, + *, + selected_organization: dict[str, Any] | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select LinkedIn org""" + payload = self._build_payload( + profile_id=profile_id, + temp_token=temp_token, + user_profile=user_profile, + account_type=account_type, + selected_organization=selected_organization, + redirect_url=redirect_url, + ) + return self._client._post( + "/v1/connect/linkedin/select-organization", data=payload + ) + + def list_pinterest_boards_for_selection( + self, x_connect_token: str, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List Pinterest boards""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return self._client._get("/v1/connect/pinterest/select-board", params=params) + + def select_pinterest_board( + self, + profile_id: str, + board_id: str, + temp_token: str, + *, + board_name: str | None = None, + user_profile: dict[str, Any] | None = None, + refresh_token: str | None = None, + expires_in: int | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select Pinterest board""" + payload = self._build_payload( + profile_id=profile_id, + board_id=board_id, + board_name=board_name, + temp_token=temp_token, + user_profile=user_profile, + refresh_token=refresh_token, + expires_in=expires_in, + redirect_url=redirect_url, + ) + return self._client._post("/v1/connect/pinterest/select-board", data=payload) + + def list_snapchat_profiles( + self, x_connect_token: str, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List Snapchat profiles""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return self._client._get("/v1/connect/snapchat/select-profile", params=params) + + def select_snapchat_profile( + self, + profile_id: str, + selected_public_profile: dict[str, Any], + temp_token: str, + user_profile: dict[str, Any], + *, + x_connect_token: str | None = None, + refresh_token: str | None = None, + expires_in: int | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select Snapchat profile""" + payload = self._build_payload( + profile_id=profile_id, + selected_public_profile=selected_public_profile, + temp_token=temp_token, + user_profile=user_profile, + refresh_token=refresh_token, + expires_in=expires_in, + redirect_url=redirect_url, + ) + return self._client._post("/v1/connect/snapchat/select-profile", data=payload) + + def connect_bluesky_credentials( + self, + identifier: str, + app_password: str, + state: str, + *, + redirect_uri: str | None = None, + ) -> dict[str, Any]: + """Connect Bluesky account""" + payload = self._build_payload( + identifier=identifier, + app_password=app_password, + state=state, + redirect_uri=redirect_uri, + ) + return self._client._post("/v1/connect/bluesky/credentials", data=payload) + + def connect_whats_app_credentials( + self, profile_id: str, access_token: str, waba_id: str, phone_number_id: str + ) -> dict[str, Any]: + """Connect WhatsApp via credentials""" + payload = self._build_payload( + profile_id=profile_id, + access_token=access_token, + waba_id=waba_id, + phone_number_id=phone_number_id, + ) + return self._client._post("/v1/connect/whatsapp/credentials", data=payload) + + def get_telegram_connect_status(self, profile_id: str) -> dict[str, Any]: + """Generate Telegram code""" + params = self._build_params( + profile_id=profile_id, + ) + return self._client._get("/v1/connect/telegram", params=params) + + def initiate_telegram_connect( + self, chat_id: str, profile_id: str + ) -> dict[str, Any]: + """Connect Telegram directly""" + payload = self._build_payload( + chat_id=chat_id, + profile_id=profile_id, + ) + return self._client._post("/v1/connect/telegram", data=payload) + + def complete_telegram_connect(self, code: str) -> dict[str, Any]: + """Check Telegram status""" + params = self._build_params( + code=code, + ) + return self._client._patch("/v1/connect/telegram", params=params) + + def get_facebook_pages(self, account_id: str) -> dict[str, Any]: + """List Facebook pages""" + return self._client._get(f"/v1/accounts/{account_id}/facebook-page") + + def update_facebook_page( + self, account_id: str, selected_page_id: str + ) -> dict[str, Any]: + """Update Facebook page""" + payload = self._build_payload( + selected_page_id=selected_page_id, + ) + return self._client._put( + f"/v1/accounts/{account_id}/facebook-page", data=payload + ) + + def get_linked_in_organizations(self, account_id: str) -> dict[str, Any]: + """List LinkedIn orgs""" + return self._client._get(f"/v1/accounts/{account_id}/linkedin-organizations") + + def update_linked_in_organization( + self, + account_id: str, + account_type: str, + *, + selected_organization: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Switch LinkedIn account type""" + payload = self._build_payload( + account_type=account_type, + selected_organization=selected_organization, + ) + return self._client._put( + f"/v1/accounts/{account_id}/linkedin-organization", data=payload + ) + + def get_pinterest_boards(self, account_id: str) -> dict[str, Any]: + """List Pinterest boards""" + return self._client._get(f"/v1/accounts/{account_id}/pinterest-boards") + + def update_pinterest_boards( + self, + account_id: str, + default_board_id: str, + *, + default_board_name: str | None = None, + ) -> dict[str, Any]: + """Set default Pinterest board""" + payload = self._build_payload( + default_board_id=default_board_id, + default_board_name=default_board_name, + ) + return self._client._put( + f"/v1/accounts/{account_id}/pinterest-boards", data=payload + ) + + def get_gmb_locations(self, account_id: str) -> dict[str, Any]: + """List GBP locations""" + return self._client._get(f"/v1/accounts/{account_id}/gmb-locations") + + def update_gmb_location( + self, account_id: str, selected_location_id: str + ) -> dict[str, Any]: + """Update GBP location""" + payload = self._build_payload( + selected_location_id=selected_location_id, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-locations", data=payload + ) + + def get_reddit_subreddits(self, account_id: str) -> dict[str, Any]: + """List Reddit subreddits""" + return self._client._get(f"/v1/accounts/{account_id}/reddit-subreddits") + + def update_reddit_subreddits( + self, account_id: str, default_subreddit: str + ) -> dict[str, Any]: + """Set default subreddit""" + payload = self._build_payload( + default_subreddit=default_subreddit, + ) + return self._client._put( + f"/v1/accounts/{account_id}/reddit-subreddits", data=payload + ) + + def get_reddit_flairs(self, account_id: str, subreddit: str) -> dict[str, Any]: + """List subreddit flairs""" + params = self._build_params( + subreddit=subreddit, + ) + return self._client._get( + f"/v1/accounts/{account_id}/reddit-flairs", params=params + ) + + async def aget_connect_url( + self, + platform: str, + profile_id: str, + *, + redirect_url: str | None = None, + headless: bool | None = False, + ) -> dict[str, Any]: + """Get OAuth connect URL (async)""" + params = self._build_params( + profile_id=profile_id, + redirect_url=redirect_url, + headless=headless, + ) + return await self._client._aget(f"/v1/connect/{platform}", params=params) + + async def ahandle_o_auth_callback( + self, platform: str, code: str, state: str, profile_id: str + ) -> dict[str, Any]: + """Complete OAuth callback (async)""" + payload = self._build_payload( + code=code, + state=state, + profile_id=profile_id, + ) + return await self._client._apost(f"/v1/connect/{platform}", data=payload) + + async def alist_facebook_pages( + self, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List Facebook pages (async)""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return await self._client._aget( + "/v1/connect/facebook/select-page", params=params + ) + + async def aselect_facebook_page( + self, + profile_id: str, + page_id: str, + temp_token: str, + *, + user_profile: dict[str, Any] | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select Facebook page (async)""" + payload = self._build_payload( + profile_id=profile_id, + page_id=page_id, + temp_token=temp_token, + user_profile=user_profile, + redirect_url=redirect_url, + ) + return await self._client._apost( + "/v1/connect/facebook/select-page", data=payload + ) + + async def alist_google_business_locations( + self, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List GBP locations (async)""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return await self._client._aget( + "/v1/connect/googlebusiness/locations", params=params + ) + + async def aselect_google_business_location( + self, + profile_id: str, + location_id: str, + temp_token: str, + *, + user_profile: dict[str, Any] | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select GBP location (async)""" + payload = self._build_payload( + profile_id=profile_id, + location_id=location_id, + temp_token=temp_token, + user_profile=user_profile, + redirect_url=redirect_url, + ) + return await self._client._apost( + "/v1/connect/googlebusiness/select-location", data=payload + ) + + async def aget_pending_o_auth_data(self, token: str) -> dict[str, Any]: + """Get pending OAuth data (async)""" + params = self._build_params( + token=token, + ) + return await self._client._aget("/v1/connect/pending-data", params=params) + + async def alist_linked_in_organizations( + self, temp_token: str, org_ids: str + ) -> dict[str, Any]: + """List LinkedIn orgs (async)""" + params = self._build_params( + temp_token=temp_token, + org_ids=org_ids, + ) + return await self._client._aget( + "/v1/connect/linkedin/organizations", params=params + ) + + async def aselect_linked_in_organization( + self, + profile_id: str, + temp_token: str, + user_profile: dict[str, Any], + account_type: str, + *, + selected_organization: dict[str, Any] | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select LinkedIn org (async)""" + payload = self._build_payload( + profile_id=profile_id, + temp_token=temp_token, + user_profile=user_profile, + account_type=account_type, + selected_organization=selected_organization, + redirect_url=redirect_url, + ) + return await self._client._apost( + "/v1/connect/linkedin/select-organization", data=payload + ) + + async def alist_pinterest_boards_for_selection( + self, x_connect_token: str, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List Pinterest boards (async)""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return await self._client._aget( + "/v1/connect/pinterest/select-board", params=params + ) + + async def aselect_pinterest_board( + self, + profile_id: str, + board_id: str, + temp_token: str, + *, + board_name: str | None = None, + user_profile: dict[str, Any] | None = None, + refresh_token: str | None = None, + expires_in: int | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select Pinterest board (async)""" + payload = self._build_payload( + profile_id=profile_id, + board_id=board_id, + board_name=board_name, + temp_token=temp_token, + user_profile=user_profile, + refresh_token=refresh_token, + expires_in=expires_in, + redirect_url=redirect_url, + ) + return await self._client._apost( + "/v1/connect/pinterest/select-board", data=payload + ) + + async def alist_snapchat_profiles( + self, x_connect_token: str, profile_id: str, temp_token: str + ) -> dict[str, Any]: + """List Snapchat profiles (async)""" + params = self._build_params( + profile_id=profile_id, + temp_token=temp_token, + ) + return await self._client._aget( + "/v1/connect/snapchat/select-profile", params=params + ) + + async def aselect_snapchat_profile( + self, + profile_id: str, + selected_public_profile: dict[str, Any], + temp_token: str, + user_profile: dict[str, Any], + *, + x_connect_token: str | None = None, + refresh_token: str | None = None, + expires_in: int | None = None, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Select Snapchat profile (async)""" + payload = self._build_payload( + profile_id=profile_id, + selected_public_profile=selected_public_profile, + temp_token=temp_token, + user_profile=user_profile, + refresh_token=refresh_token, + expires_in=expires_in, + redirect_url=redirect_url, + ) + return await self._client._apost( + "/v1/connect/snapchat/select-profile", data=payload + ) + + async def aconnect_bluesky_credentials( + self, + identifier: str, + app_password: str, + state: str, + *, + redirect_uri: str | None = None, + ) -> dict[str, Any]: + """Connect Bluesky account (async)""" + payload = self._build_payload( + identifier=identifier, + app_password=app_password, + state=state, + redirect_uri=redirect_uri, + ) + return await self._client._apost( + "/v1/connect/bluesky/credentials", data=payload + ) + + async def aconnect_whats_app_credentials( + self, profile_id: str, access_token: str, waba_id: str, phone_number_id: str + ) -> dict[str, Any]: + """Connect WhatsApp via credentials (async)""" + payload = self._build_payload( + profile_id=profile_id, + access_token=access_token, + waba_id=waba_id, + phone_number_id=phone_number_id, + ) + return await self._client._apost( + "/v1/connect/whatsapp/credentials", data=payload + ) + + async def aget_telegram_connect_status(self, profile_id: str) -> dict[str, Any]: + """Generate Telegram code (async)""" + params = self._build_params( + profile_id=profile_id, + ) + return await self._client._aget("/v1/connect/telegram", params=params) + + async def ainitiate_telegram_connect( + self, chat_id: str, profile_id: str + ) -> dict[str, Any]: + """Connect Telegram directly (async)""" + payload = self._build_payload( + chat_id=chat_id, + profile_id=profile_id, + ) + return await self._client._apost("/v1/connect/telegram", data=payload) + + async def acomplete_telegram_connect(self, code: str) -> dict[str, Any]: + """Check Telegram status (async)""" + params = self._build_params( + code=code, + ) + return await self._client._apatch("/v1/connect/telegram", params=params) + + async def aget_facebook_pages(self, account_id: str) -> dict[str, Any]: + """List Facebook pages (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/facebook-page") + + async def aupdate_facebook_page( + self, account_id: str, selected_page_id: str + ) -> dict[str, Any]: + """Update Facebook page (async)""" + payload = self._build_payload( + selected_page_id=selected_page_id, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/facebook-page", data=payload + ) + + async def aget_linked_in_organizations(self, account_id: str) -> dict[str, Any]: + """List LinkedIn orgs (async)""" + return await self._client._aget( + f"/v1/accounts/{account_id}/linkedin-organizations" + ) + + async def aupdate_linked_in_organization( + self, + account_id: str, + account_type: str, + *, + selected_organization: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Switch LinkedIn account type (async)""" + payload = self._build_payload( + account_type=account_type, + selected_organization=selected_organization, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/linkedin-organization", data=payload + ) + + async def aget_pinterest_boards(self, account_id: str) -> dict[str, Any]: + """List Pinterest boards (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/pinterest-boards") + + async def aupdate_pinterest_boards( + self, + account_id: str, + default_board_id: str, + *, + default_board_name: str | None = None, + ) -> dict[str, Any]: + """Set default Pinterest board (async)""" + payload = self._build_payload( + default_board_id=default_board_id, + default_board_name=default_board_name, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/pinterest-boards", data=payload + ) + + async def aget_gmb_locations(self, account_id: str) -> dict[str, Any]: + """List GBP locations (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/gmb-locations") + + async def aupdate_gmb_location( + self, account_id: str, selected_location_id: str + ) -> dict[str, Any]: + """Update GBP location (async)""" + payload = self._build_payload( + selected_location_id=selected_location_id, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-locations", data=payload + ) + + async def aget_reddit_subreddits(self, account_id: str) -> dict[str, Any]: + """List Reddit subreddits (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/reddit-subreddits") + + async def aupdate_reddit_subreddits( + self, account_id: str, default_subreddit: str + ) -> dict[str, Any]: + """Set default subreddit (async)""" + payload = self._build_payload( + default_subreddit=default_subreddit, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/reddit-subreddits", data=payload + ) + + async def aget_reddit_flairs( + self, account_id: str, subreddit: str + ) -> dict[str, Any]: + """List subreddit flairs (async)""" + params = self._build_params( + subreddit=subreddit, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/reddit-flairs", params=params + ) diff --git a/src/late/resources/_generated/gmb_attributes.py b/src/late/resources/_generated/gmb_attributes.py new file mode 100644 index 0000000..79a3293 --- /dev/null +++ b/src/late/resources/_generated/gmb_attributes.py @@ -0,0 +1,81 @@ +""" +Auto-generated gmb_attributes resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class GmbAttributesResource: + """ + gmb_attributes operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_google_business_attributes(self, account_id: str) -> dict[str, Any]: + """Get Google Business Profile location attributes""" + return self._client._get(f"/v1/accounts/{account_id}/gmb-attributes") + + def update_google_business_attributes( + self, account_id: str, attributes: list[dict[str, Any]], attribute_mask: str + ) -> dict[str, Any]: + """Update Google Business Profile location attributes""" + payload = self._build_payload( + attributes=attributes, + attribute_mask=attribute_mask, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-attributes", data=payload + ) + + async def aget_google_business_attributes(self, account_id: str) -> dict[str, Any]: + """Get Google Business Profile location attributes (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/gmb-attributes") + + async def aupdate_google_business_attributes( + self, account_id: str, attributes: list[dict[str, Any]], attribute_mask: str + ) -> dict[str, Any]: + """Update Google Business Profile location attributes (async)""" + payload = self._build_payload( + attributes=attributes, + attribute_mask=attribute_mask, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-attributes", data=payload + ) diff --git a/src/late/resources/_generated/gmb_food_menus.py b/src/late/resources/_generated/gmb_food_menus.py new file mode 100644 index 0000000..12df724 --- /dev/null +++ b/src/late/resources/_generated/gmb_food_menus.py @@ -0,0 +1,81 @@ +""" +Auto-generated gmb_food_menus resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class GmbFoodMenusResource: + """ + gmb_food_menus operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_google_business_food_menus(self, account_id: str) -> dict[str, Any]: + """Get Google Business Profile food menus""" + return self._client._get(f"/v1/accounts/{account_id}/gmb-food-menus") + + def update_google_business_food_menus( + self, account_id: str, menus: list[Any], *, update_mask: str | None = None + ) -> dict[str, Any]: + """Update Google Business Profile food menus""" + payload = self._build_payload( + menus=menus, + update_mask=update_mask, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-food-menus", data=payload + ) + + async def aget_google_business_food_menus(self, account_id: str) -> dict[str, Any]: + """Get Google Business Profile food menus (async)""" + return await self._client._aget(f"/v1/accounts/{account_id}/gmb-food-menus") + + async def aupdate_google_business_food_menus( + self, account_id: str, menus: list[Any], *, update_mask: str | None = None + ) -> dict[str, Any]: + """Update Google Business Profile food menus (async)""" + payload = self._build_payload( + menus=menus, + update_mask=update_mask, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-food-menus", data=payload + ) diff --git a/src/late/resources/_generated/gmb_location_details.py b/src/late/resources/_generated/gmb_location_details.py new file mode 100644 index 0000000..25c5d7d --- /dev/null +++ b/src/late/resources/_generated/gmb_location_details.py @@ -0,0 +1,119 @@ +""" +Auto-generated gmb_location_details resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class GmbLocationDetailsResource: + """ + gmb_location_details operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_google_business_location_details( + self, account_id: str, *, read_mask: str | None = None + ) -> dict[str, Any]: + """Get Google Business Profile location details""" + params = self._build_params( + read_mask=read_mask, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-location-details", params=params + ) + + def update_google_business_location_details( + self, + account_id: str, + update_mask: str, + *, + regular_hours: dict[str, Any] | None = None, + special_hours: dict[str, Any] | None = None, + profile: dict[str, Any] | None = None, + website_uri: str | None = None, + phone_numbers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Update Google Business Profile location details""" + payload = self._build_payload( + update_mask=update_mask, + regular_hours=regular_hours, + special_hours=special_hours, + profile=profile, + website_uri=website_uri, + phone_numbers=phone_numbers, + ) + return self._client._put( + f"/v1/accounts/{account_id}/gmb-location-details", data=payload + ) + + async def aget_google_business_location_details( + self, account_id: str, *, read_mask: str | None = None + ) -> dict[str, Any]: + """Get Google Business Profile location details (async)""" + params = self._build_params( + read_mask=read_mask, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-location-details", params=params + ) + + async def aupdate_google_business_location_details( + self, + account_id: str, + update_mask: str, + *, + regular_hours: dict[str, Any] | None = None, + special_hours: dict[str, Any] | None = None, + profile: dict[str, Any] | None = None, + website_uri: str | None = None, + phone_numbers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Update Google Business Profile location details (async)""" + payload = self._build_payload( + update_mask=update_mask, + regular_hours=regular_hours, + special_hours=special_hours, + profile=profile, + website_uri=website_uri, + phone_numbers=phone_numbers, + ) + return await self._client._aput( + f"/v1/accounts/{account_id}/gmb-location-details", data=payload + ) diff --git a/src/late/resources/_generated/gmb_media.py b/src/late/resources/_generated/gmb_media.py new file mode 100644 index 0000000..eb85ed8 --- /dev/null +++ b/src/late/resources/_generated/gmb_media.py @@ -0,0 +1,139 @@ +""" +Auto-generated gmb_media resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class GmbMediaResource: + """ + gmb_media operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_google_business_media( + self, + account_id: str, + *, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List Google Business Profile media (photos)""" + params = self._build_params( + page_size=page_size, + page_token=page_token, + ) + return self._client._get(f"/v1/accounts/{account_id}/gmb-media", params=params) + + def create_google_business_media( + self, + account_id: str, + source_url: str, + *, + media_format: str | None = "PHOTO", + description: str | None = None, + category: str | None = None, + ) -> dict[str, Any]: + """Upload a photo to Google Business Profile""" + payload = self._build_payload( + source_url=source_url, + media_format=media_format, + description=description, + category=category, + ) + return self._client._post(f"/v1/accounts/{account_id}/gmb-media", data=payload) + + def delete_google_business_media( + self, account_id: str, media_id: str + ) -> dict[str, Any]: + """Delete a photo from Google Business Profile""" + params = self._build_params( + media_id=media_id, + ) + return self._client._delete( + f"/v1/accounts/{account_id}/gmb-media", params=params + ) + + async def alist_google_business_media( + self, + account_id: str, + *, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List Google Business Profile media (photos) (async)""" + params = self._build_params( + page_size=page_size, + page_token=page_token, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-media", params=params + ) + + async def acreate_google_business_media( + self, + account_id: str, + source_url: str, + *, + media_format: str | None = "PHOTO", + description: str | None = None, + category: str | None = None, + ) -> dict[str, Any]: + """Upload a photo to Google Business Profile (async)""" + payload = self._build_payload( + source_url=source_url, + media_format=media_format, + description=description, + category=category, + ) + return await self._client._apost( + f"/v1/accounts/{account_id}/gmb-media", data=payload + ) + + async def adelete_google_business_media( + self, account_id: str, media_id: str + ) -> dict[str, Any]: + """Delete a photo from Google Business Profile (async)""" + params = self._build_params( + media_id=media_id, + ) + return await self._client._adelete( + f"/v1/accounts/{account_id}/gmb-media", params=params + ) diff --git a/src/late/resources/_generated/gmb_place_actions.py b/src/late/resources/_generated/gmb_place_actions.py new file mode 100644 index 0000000..3766c69 --- /dev/null +++ b/src/late/resources/_generated/gmb_place_actions.py @@ -0,0 +1,127 @@ +""" +Auto-generated gmb_place_actions resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class GmbPlaceActionsResource: + """ + gmb_place_actions operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_google_business_place_actions( + self, + account_id: str, + *, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List place action links (booking, ordering, reservations)""" + params = self._build_params( + page_size=page_size, + page_token=page_token, + ) + return self._client._get( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + def create_google_business_place_action( + self, account_id: str, uri: str, place_action_type: str + ) -> dict[str, Any]: + """Create a place action link (booking, ordering, reservation)""" + payload = self._build_payload( + uri=uri, + place_action_type=place_action_type, + ) + return self._client._post( + f"/v1/accounts/{account_id}/gmb-place-actions", data=payload + ) + + def delete_google_business_place_action( + self, account_id: str, name: str + ) -> dict[str, Any]: + """Delete a place action link""" + params = self._build_params( + name=name, + ) + return self._client._delete( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + async def alist_google_business_place_actions( + self, + account_id: str, + *, + page_size: int | None = 100, + page_token: str | None = None, + ) -> dict[str, Any]: + """List place action links (booking, ordering, reservations) (async)""" + params = self._build_params( + page_size=page_size, + page_token=page_token, + ) + return await self._client._aget( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) + + async def acreate_google_business_place_action( + self, account_id: str, uri: str, place_action_type: str + ) -> dict[str, Any]: + """Create a place action link (booking, ordering, reservation) (async)""" + payload = self._build_payload( + uri=uri, + place_action_type=place_action_type, + ) + return await self._client._apost( + f"/v1/accounts/{account_id}/gmb-place-actions", data=payload + ) + + async def adelete_google_business_place_action( + self, account_id: str, name: str + ) -> dict[str, Any]: + """Delete a place action link (async)""" + params = self._build_params( + name=name, + ) + return await self._client._adelete( + f"/v1/accounts/{account_id}/gmb-place-actions", params=params + ) diff --git a/src/late/resources/_generated/inbox.py b/src/late/resources/_generated/inbox.py new file mode 100644 index 0000000..b86ebd7 --- /dev/null +++ b/src/late/resources/_generated/inbox.py @@ -0,0 +1,547 @@ +""" +Auto-generated inbox resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datetime import datetime + + from ..client.base import BaseClient + + +class InboxResource: + """ + inbox operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_inbox_conversations( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + status: str | None = None, + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List conversations across all accounts""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + status=status, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return self._client._get("/v1/inbox/conversations", params=params) + + def get_inbox_conversation( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """Get conversation details""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get( + f"/v1/inbox/conversations/{conversation_id}", params=params + ) + + def update_inbox_conversation( + self, conversation_id: str, account_id: str, status: str + ) -> dict[str, Any]: + """Update conversation status""" + payload = self._build_payload( + account_id=account_id, + status=status, + ) + return self._client._put( + f"/v1/inbox/conversations/{conversation_id}", data=payload + ) + + def get_inbox_conversation_messages( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """Get messages in a conversation""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get( + f"/v1/inbox/conversations/{conversation_id}/messages", params=params + ) + + def send_inbox_message( + self, conversation_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Send a message""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return self._client._post( + f"/v1/inbox/conversations/{conversation_id}/messages", data=payload + ) + + def list_inbox_comments( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_comments: int | None = None, + since: datetime | str | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List posts with comments across all accounts""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_comments=min_comments, + since=since, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return self._client._get("/v1/inbox/comments", params=params) + + def get_inbox_post_comments( + self, + post_id: str, + account_id: str, + *, + subreddit: str | None = None, + limit: int | None = 25, + cursor: str | None = None, + comment_id: str | None = None, + ) -> dict[str, Any]: + """Get comments for a post""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + limit=limit, + cursor=cursor, + comment_id=comment_id, + ) + return self._client._get(f"/v1/inbox/comments/{post_id}", params=params) + + def reply_to_inbox_post( + self, + post_id: str, + account_id: str, + message: str, + *, + comment_id: str | None = None, + subreddit: str | None = None, + parent_cid: str | None = None, + root_uri: str | None = None, + root_cid: str | None = None, + ) -> dict[str, Any]: + """Reply to a post or comment""" + payload = self._build_payload( + account_id=account_id, + message=message, + comment_id=comment_id, + subreddit=subreddit, + parent_cid=parent_cid, + root_uri=root_uri, + root_cid=root_cid, + ) + return self._client._post(f"/v1/inbox/comments/{post_id}", data=payload) + + def delete_inbox_comment( + self, post_id: str, account_id: str, comment_id: str + ) -> dict[str, Any]: + """Delete a comment""" + params = self._build_params( + account_id=account_id, + comment_id=comment_id, + ) + return self._client._delete(f"/v1/inbox/comments/{post_id}", params=params) + + def hide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Hide a comment""" + payload = self._build_payload( + account_id=account_id, + ) + return self._client._post( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", data=payload + ) + + def unhide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Unhide a comment""" + params = self._build_params( + account_id=account_id, + ) + return self._client._delete( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", params=params + ) + + def like_inbox_comment( + self, post_id: str, comment_id: str, account_id: str, *, cid: str | None = None + ) -> dict[str, Any]: + """Like a comment""" + payload = self._build_payload( + account_id=account_id, + cid=cid, + ) + return self._client._post( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", data=payload + ) + + def unlike_inbox_comment( + self, + post_id: str, + comment_id: str, + account_id: str, + *, + like_uri: str | None = None, + ) -> dict[str, Any]: + """Unlike a comment""" + params = self._build_params( + account_id=account_id, + like_uri=like_uri, + ) + return self._client._delete( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", params=params + ) + + def list_inbox_reviews( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_rating: int | None = None, + max_rating: int | None = None, + has_reply: bool | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 25, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List reviews across all accounts""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_rating=min_rating, + max_rating=max_rating, + has_reply=has_reply, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return self._client._get("/v1/inbox/reviews", params=params) + + def reply_to_inbox_review( + self, review_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Reply to a review""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return self._client._post(f"/v1/inbox/reviews/{review_id}/reply", data=payload) + + def delete_inbox_review_reply( + self, review_id: str, account_id: str + ) -> dict[str, Any]: + """Delete a review reply""" + return self._client._delete(f"/v1/inbox/reviews/{review_id}/reply") + + async def alist_inbox_conversations( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + status: str | None = None, + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List conversations across all accounts (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + status=status, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return await self._client._aget("/v1/inbox/conversations", params=params) + + async def aget_inbox_conversation( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """Get conversation details (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget( + f"/v1/inbox/conversations/{conversation_id}", params=params + ) + + async def aupdate_inbox_conversation( + self, conversation_id: str, account_id: str, status: str + ) -> dict[str, Any]: + """Update conversation status (async)""" + payload = self._build_payload( + account_id=account_id, + status=status, + ) + return await self._client._aput( + f"/v1/inbox/conversations/{conversation_id}", data=payload + ) + + async def aget_inbox_conversation_messages( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """Get messages in a conversation (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget( + f"/v1/inbox/conversations/{conversation_id}/messages", params=params + ) + + async def asend_inbox_message( + self, conversation_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Send a message (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return await self._client._apost( + f"/v1/inbox/conversations/{conversation_id}/messages", data=payload + ) + + async def alist_inbox_comments( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_comments: int | None = None, + since: datetime | str | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List posts with comments across all accounts (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_comments=min_comments, + since=since, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return await self._client._aget("/v1/inbox/comments", params=params) + + async def aget_inbox_post_comments( + self, + post_id: str, + account_id: str, + *, + subreddit: str | None = None, + limit: int | None = 25, + cursor: str | None = None, + comment_id: str | None = None, + ) -> dict[str, Any]: + """Get comments for a post (async)""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + limit=limit, + cursor=cursor, + comment_id=comment_id, + ) + return await self._client._aget(f"/v1/inbox/comments/{post_id}", params=params) + + async def areply_to_inbox_post( + self, + post_id: str, + account_id: str, + message: str, + *, + comment_id: str | None = None, + subreddit: str | None = None, + parent_cid: str | None = None, + root_uri: str | None = None, + root_cid: str | None = None, + ) -> dict[str, Any]: + """Reply to a post or comment (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + comment_id=comment_id, + subreddit=subreddit, + parent_cid=parent_cid, + root_uri=root_uri, + root_cid=root_cid, + ) + return await self._client._apost(f"/v1/inbox/comments/{post_id}", data=payload) + + async def adelete_inbox_comment( + self, post_id: str, account_id: str, comment_id: str + ) -> dict[str, Any]: + """Delete a comment (async)""" + params = self._build_params( + account_id=account_id, + comment_id=comment_id, + ) + return await self._client._adelete( + f"/v1/inbox/comments/{post_id}", params=params + ) + + async def ahide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Hide a comment (async)""" + payload = self._build_payload( + account_id=account_id, + ) + return await self._client._apost( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", data=payload + ) + + async def aunhide_inbox_comment( + self, post_id: str, comment_id: str, account_id: str + ) -> dict[str, Any]: + """Unhide a comment (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._adelete( + f"/v1/inbox/comments/{post_id}/{comment_id}/hide", params=params + ) + + async def alike_inbox_comment( + self, post_id: str, comment_id: str, account_id: str, *, cid: str | None = None + ) -> dict[str, Any]: + """Like a comment (async)""" + payload = self._build_payload( + account_id=account_id, + cid=cid, + ) + return await self._client._apost( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", data=payload + ) + + async def aunlike_inbox_comment( + self, + post_id: str, + comment_id: str, + account_id: str, + *, + like_uri: str | None = None, + ) -> dict[str, Any]: + """Unlike a comment (async)""" + params = self._build_params( + account_id=account_id, + like_uri=like_uri, + ) + return await self._client._adelete( + f"/v1/inbox/comments/{post_id}/{comment_id}/like", params=params + ) + + async def alist_inbox_reviews( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_rating: int | None = None, + max_rating: int | None = None, + has_reply: bool | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 25, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List reviews across all accounts (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_rating=min_rating, + max_rating=max_rating, + has_reply=has_reply, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return await self._client._aget("/v1/inbox/reviews", params=params) + + async def areply_to_inbox_review( + self, review_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Reply to a review (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return await self._client._apost( + f"/v1/inbox/reviews/{review_id}/reply", data=payload + ) + + async def adelete_inbox_review_reply( + self, review_id: str, account_id: str + ) -> dict[str, Any]: + """Delete a review reply (async)""" + return await self._client._adelete(f"/v1/inbox/reviews/{review_id}/reply") diff --git a/src/late/resources/_generated/invites.py b/src/late/resources/_generated/invites.py new file mode 100644 index 0000000..d29eeb8 --- /dev/null +++ b/src/late/resources/_generated/invites.py @@ -0,0 +1,69 @@ +""" +Auto-generated invites resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class InvitesResource: + """ + Team invitations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def create_invite_token( + self, scope: str, *, profile_ids: list[str] | None = None + ) -> dict[str, Any]: + """Create invite token""" + payload = self._build_payload( + scope=scope, + profile_ids=profile_ids, + ) + return self._client._post("/v1/invite/tokens", data=payload) + + async def acreate_invite_token( + self, scope: str, *, profile_ids: list[str] | None = None + ) -> dict[str, Any]: + """Create invite token (async)""" + payload = self._build_payload( + scope=scope, + profile_ids=profile_ids, + ) + return await self._client._apost("/v1/invite/tokens", data=payload) diff --git a/src/late/resources/_generated/logs.py b/src/late/resources/_generated/logs.py new file mode 100644 index 0000000..a7577db --- /dev/null +++ b/src/late/resources/_generated/logs.py @@ -0,0 +1,153 @@ +""" +Auto-generated logs resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class LogsResource: + """ + Publishing logs for debugging. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_posts_logs( + self, + *, + status: str | None = None, + platform: str | None = None, + action: str | None = None, + days: int | None = 7, + limit: int | None = 50, + skip: int | None = 0, + search: str | None = None, + ) -> dict[str, Any]: + """List publishing logs""" + params = self._build_params( + status=status, + platform=platform, + action=action, + days=days, + limit=limit, + skip=skip, + search=search, + ) + return self._client._get("/v1/posts/logs", params=params) + + def list_connection_logs( + self, + *, + platform: str | None = None, + event_type: str | None = None, + status: str | None = None, + days: int | None = 7, + limit: int | None = 50, + skip: int | None = 0, + ) -> dict[str, Any]: + """List connection logs""" + params = self._build_params( + platform=platform, + event_type=event_type, + status=status, + days=days, + limit=limit, + skip=skip, + ) + return self._client._get("/v1/connections/logs", params=params) + + def get_post_logs(self, post_id: str, *, limit: int | None = 50) -> dict[str, Any]: + """Get post logs""" + params = self._build_params( + limit=limit, + ) + return self._client._get(f"/v1/posts/{post_id}/logs", params=params) + + async def alist_posts_logs( + self, + *, + status: str | None = None, + platform: str | None = None, + action: str | None = None, + days: int | None = 7, + limit: int | None = 50, + skip: int | None = 0, + search: str | None = None, + ) -> dict[str, Any]: + """List publishing logs (async)""" + params = self._build_params( + status=status, + platform=platform, + action=action, + days=days, + limit=limit, + skip=skip, + search=search, + ) + return await self._client._aget("/v1/posts/logs", params=params) + + async def alist_connection_logs( + self, + *, + platform: str | None = None, + event_type: str | None = None, + status: str | None = None, + days: int | None = 7, + limit: int | None = 50, + skip: int | None = 0, + ) -> dict[str, Any]: + """List connection logs (async)""" + params = self._build_params( + platform=platform, + event_type=event_type, + status=status, + days=days, + limit=limit, + skip=skip, + ) + return await self._client._aget("/v1/connections/logs", params=params) + + async def aget_post_logs( + self, post_id: str, *, limit: int | None = 50 + ) -> dict[str, Any]: + """Get post logs (async)""" + params = self._build_params( + limit=limit, + ) + return await self._client._aget(f"/v1/posts/{post_id}/logs", params=params) diff --git a/src/late/resources/_generated/media.py b/src/late/resources/_generated/media.py new file mode 100644 index 0000000..6f99ecf --- /dev/null +++ b/src/late/resources/_generated/media.py @@ -0,0 +1,71 @@ +""" +Auto-generated media resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class MediaResource: + """ + Upload and manage media files. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_media_presigned_url( + self, filename: str, content_type: str, *, size: int | None = None + ) -> dict[str, Any]: + """Get presigned upload URL""" + payload = self._build_payload( + filename=filename, + content_type=content_type, + size=size, + ) + return self._client._post("/v1/media/presign", data=payload) + + async def aget_media_presigned_url( + self, filename: str, content_type: str, *, size: int | None = None + ) -> dict[str, Any]: + """Get presigned upload URL (async)""" + payload = self._build_payload( + filename=filename, + content_type=content_type, + size=size, + ) + return await self._client._apost("/v1/media/presign", data=payload) diff --git a/src/late/resources/_generated/messages.py b/src/late/resources/_generated/messages.py new file mode 100644 index 0000000..2c87e87 --- /dev/null +++ b/src/late/resources/_generated/messages.py @@ -0,0 +1,263 @@ +""" +Auto-generated messages resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class MessagesResource: + """ + messages operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_inbox_conversations( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + status: str | None = None, + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List conversations""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + status=status, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return self._client._get("/v1/inbox/conversations", params=params) + + def get_inbox_conversation( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """Get conversation""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get( + f"/v1/inbox/conversations/{conversation_id}", params=params + ) + + def update_inbox_conversation( + self, conversation_id: str, account_id: str, status: str + ) -> dict[str, Any]: + """Update conversation status""" + payload = self._build_payload( + account_id=account_id, + status=status, + ) + return self._client._put( + f"/v1/inbox/conversations/{conversation_id}", data=payload + ) + + def get_inbox_conversation_messages( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """List messages""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get( + f"/v1/inbox/conversations/{conversation_id}/messages", params=params + ) + + def send_inbox_message( + self, + conversation_id: str, + account_id: str, + *, + message: str | None = None, + quick_replies: list[dict[str, Any]] | None = None, + buttons: list[dict[str, Any]] | None = None, + template: dict[str, Any] | None = None, + reply_markup: dict[str, Any] | None = None, + messaging_type: str | None = None, + message_tag: str | None = None, + reply_to: str | None = None, + ) -> dict[str, Any]: + """Send message""" + payload = self._build_payload( + account_id=account_id, + message=message, + quick_replies=quick_replies, + buttons=buttons, + template=template, + reply_markup=reply_markup, + messaging_type=messaging_type, + message_tag=message_tag, + reply_to=reply_to, + ) + return self._client._post( + f"/v1/inbox/conversations/{conversation_id}/messages", data=payload + ) + + def edit_inbox_message( + self, + conversation_id: str, + message_id: str, + account_id: str, + *, + text: str | None = None, + reply_markup: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Edit message""" + payload = self._build_payload( + account_id=account_id, + text=text, + reply_markup=reply_markup, + ) + return self._client._patch( + f"/v1/inbox/conversations/{conversation_id}/messages/{message_id}", + data=payload, + ) + + async def alist_inbox_conversations( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + status: str | None = None, + sort_order: str | None = "desc", + limit: int | None = 50, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List conversations (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + status=status, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return await self._client._aget("/v1/inbox/conversations", params=params) + + async def aget_inbox_conversation( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """Get conversation (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget( + f"/v1/inbox/conversations/{conversation_id}", params=params + ) + + async def aupdate_inbox_conversation( + self, conversation_id: str, account_id: str, status: str + ) -> dict[str, Any]: + """Update conversation status (async)""" + payload = self._build_payload( + account_id=account_id, + status=status, + ) + return await self._client._aput( + f"/v1/inbox/conversations/{conversation_id}", data=payload + ) + + async def aget_inbox_conversation_messages( + self, conversation_id: str, account_id: str + ) -> dict[str, Any]: + """List messages (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget( + f"/v1/inbox/conversations/{conversation_id}/messages", params=params + ) + + async def asend_inbox_message( + self, + conversation_id: str, + account_id: str, + *, + message: str | None = None, + quick_replies: list[dict[str, Any]] | None = None, + buttons: list[dict[str, Any]] | None = None, + template: dict[str, Any] | None = None, + reply_markup: dict[str, Any] | None = None, + messaging_type: str | None = None, + message_tag: str | None = None, + reply_to: str | None = None, + ) -> dict[str, Any]: + """Send message (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + quick_replies=quick_replies, + buttons=buttons, + template=template, + reply_markup=reply_markup, + messaging_type=messaging_type, + message_tag=message_tag, + reply_to=reply_to, + ) + return await self._client._apost( + f"/v1/inbox/conversations/{conversation_id}/messages", data=payload + ) + + async def aedit_inbox_message( + self, + conversation_id: str, + message_id: str, + account_id: str, + *, + text: str | None = None, + reply_markup: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Edit message (async)""" + payload = self._build_payload( + account_id=account_id, + text=text, + reply_markup=reply_markup, + ) + return await self._client._apatch( + f"/v1/inbox/conversations/{conversation_id}/messages/{message_id}", + data=payload, + ) diff --git a/src/late/resources/_generated/posts.py b/src/late/resources/_generated/posts.py new file mode 100644 index 0000000..075a2ab --- /dev/null +++ b/src/late/resources/_generated/posts.py @@ -0,0 +1,289 @@ +""" +Auto-generated posts resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datetime import datetime + + from ..client.base import BaseClient + + +class PostsResource: + """ + Create, schedule, and manage social media posts. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_posts( + self, + *, + page: int | None = 1, + limit: int | None = 10, + status: str | None = None, + platform: str | None = None, + profile_id: str | None = None, + created_by: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + include_hidden: bool | None = False, + search: str | None = None, + sort_by: str | None = "scheduled-desc", + ) -> dict[str, Any]: + """List posts""" + params = self._build_params( + page=page, + limit=limit, + status=status, + platform=platform, + profile_id=profile_id, + created_by=created_by, + date_from=date_from, + date_to=date_to, + include_hidden=include_hidden, + search=search, + sort_by=sort_by, + ) + return self._client._get("/v1/posts", params=params) + + def create_post( + self, + *, + title: str | None = None, + content: str | None = None, + media_items: list[dict[str, Any]] | None = None, + platforms: list[dict[str, Any]] | None = None, + scheduled_for: datetime | str | None = None, + publish_now: bool | None = False, + is_draft: bool | None = False, + timezone: str | None = "UTC", + tags: list[str] | None = None, + hashtags: list[str] | None = None, + mentions: list[str] | None = None, + crossposting_enabled: bool | None = True, + metadata: dict[str, Any] | None = None, + tiktok_settings: Any | None = None, + recycling: Any | None = None, + queued_from_profile: str | None = None, + queue_id: str | None = None, + ) -> dict[str, Any]: + """Create post""" + payload = self._build_payload( + title=title, + content=content, + media_items=media_items, + platforms=platforms, + scheduled_for=scheduled_for, + publish_now=publish_now, + is_draft=is_draft, + timezone=timezone, + tags=tags, + hashtags=hashtags, + mentions=mentions, + crossposting_enabled=crossposting_enabled, + metadata=metadata, + tiktok_settings=tiktok_settings, + recycling=recycling, + queued_from_profile=queued_from_profile, + queue_id=queue_id, + ) + return self._client._post("/v1/posts", data=payload) + + def get_post(self, post_id: str) -> dict[str, Any]: + """Get post""" + return self._client._get(f"/v1/posts/{post_id}") + + def update_post( + self, + post_id: str, + *, + content: str | None = None, + scheduled_for: datetime | str | None = None, + tiktok_settings: Any | None = None, + recycling: Any | None = None, + ) -> dict[str, Any]: + """Update post""" + payload = self._build_payload( + content=content, + scheduled_for=scheduled_for, + tiktok_settings=tiktok_settings, + recycling=recycling, + ) + return self._client._put(f"/v1/posts/{post_id}", data=payload) + + def delete_post(self, post_id: str) -> dict[str, Any]: + """Delete post""" + return self._client._delete(f"/v1/posts/{post_id}") + + def bulk_upload_posts(self, *, dry_run: bool | None = False) -> dict[str, Any]: + """Bulk upload from CSV""" + params = self._build_params( + dry_run=dry_run, + ) + return self._client._post("/v1/posts/bulk-upload", params=params) + + def retry_post(self, post_id: str) -> dict[str, Any]: + """Retry failed post""" + return self._client._post(f"/v1/posts/{post_id}/retry") + + def unpublish_post(self, post_id: str, platform: str) -> dict[str, Any]: + """Unpublish post""" + payload = self._build_payload( + platform=platform, + ) + return self._client._post(f"/v1/posts/{post_id}/unpublish", data=payload) + + async def alist_posts( + self, + *, + page: int | None = 1, + limit: int | None = 10, + status: str | None = None, + platform: str | None = None, + profile_id: str | None = None, + created_by: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + include_hidden: bool | None = False, + search: str | None = None, + sort_by: str | None = "scheduled-desc", + ) -> dict[str, Any]: + """List posts (async)""" + params = self._build_params( + page=page, + limit=limit, + status=status, + platform=platform, + profile_id=profile_id, + created_by=created_by, + date_from=date_from, + date_to=date_to, + include_hidden=include_hidden, + search=search, + sort_by=sort_by, + ) + return await self._client._aget("/v1/posts", params=params) + + async def acreate_post( + self, + *, + title: str | None = None, + content: str | None = None, + media_items: list[dict[str, Any]] | None = None, + platforms: list[dict[str, Any]] | None = None, + scheduled_for: datetime | str | None = None, + publish_now: bool | None = False, + is_draft: bool | None = False, + timezone: str | None = "UTC", + tags: list[str] | None = None, + hashtags: list[str] | None = None, + mentions: list[str] | None = None, + crossposting_enabled: bool | None = True, + metadata: dict[str, Any] | None = None, + tiktok_settings: Any | None = None, + recycling: Any | None = None, + queued_from_profile: str | None = None, + queue_id: str | None = None, + ) -> dict[str, Any]: + """Create post (async)""" + payload = self._build_payload( + title=title, + content=content, + media_items=media_items, + platforms=platforms, + scheduled_for=scheduled_for, + publish_now=publish_now, + is_draft=is_draft, + timezone=timezone, + tags=tags, + hashtags=hashtags, + mentions=mentions, + crossposting_enabled=crossposting_enabled, + metadata=metadata, + tiktok_settings=tiktok_settings, + recycling=recycling, + queued_from_profile=queued_from_profile, + queue_id=queue_id, + ) + return await self._client._apost("/v1/posts", data=payload) + + async def aget_post(self, post_id: str) -> dict[str, Any]: + """Get post (async)""" + return await self._client._aget(f"/v1/posts/{post_id}") + + async def aupdate_post( + self, + post_id: str, + *, + content: str | None = None, + scheduled_for: datetime | str | None = None, + tiktok_settings: Any | None = None, + recycling: Any | None = None, + ) -> dict[str, Any]: + """Update post (async)""" + payload = self._build_payload( + content=content, + scheduled_for=scheduled_for, + tiktok_settings=tiktok_settings, + recycling=recycling, + ) + return await self._client._aput(f"/v1/posts/{post_id}", data=payload) + + async def adelete_post(self, post_id: str) -> dict[str, Any]: + """Delete post (async)""" + return await self._client._adelete(f"/v1/posts/{post_id}") + + async def abulk_upload_posts( + self, *, dry_run: bool | None = False + ) -> dict[str, Any]: + """Bulk upload from CSV (async)""" + params = self._build_params( + dry_run=dry_run, + ) + return await self._client._apost("/v1/posts/bulk-upload", params=params) + + async def aretry_post(self, post_id: str) -> dict[str, Any]: + """Retry failed post (async)""" + return await self._client._apost(f"/v1/posts/{post_id}/retry") + + async def aunpublish_post(self, post_id: str, platform: str) -> dict[str, Any]: + """Unpublish post (async)""" + payload = self._build_payload( + platform=platform, + ) + return await self._client._apost(f"/v1/posts/{post_id}/unpublish", data=payload) diff --git a/src/late/resources/_generated/profiles.py b/src/late/resources/_generated/profiles.py new file mode 100644 index 0000000..5a34709 --- /dev/null +++ b/src/late/resources/_generated/profiles.py @@ -0,0 +1,141 @@ +""" +Auto-generated profiles resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class ProfilesResource: + """ + Manage workspace profiles. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_profiles( + self, *, include_over_limit: bool | None = False + ) -> dict[str, Any]: + """List profiles""" + params = self._build_params( + include_over_limit=include_over_limit, + ) + return self._client._get("/v1/profiles", params=params) + + def create_profile( + self, name: str, *, description: str | None = None, color: str | None = None + ) -> dict[str, Any]: + """Create profile""" + payload = self._build_payload( + name=name, + description=description, + color=color, + ) + return self._client._post("/v1/profiles", data=payload) + + def get_profile(self, profile_id: str) -> dict[str, Any]: + """Get profile""" + return self._client._get(f"/v1/profiles/{profile_id}") + + def update_profile( + self, + profile_id: str, + *, + name: str | None = None, + description: str | None = None, + color: str | None = None, + is_default: bool | None = None, + ) -> dict[str, Any]: + """Update profile""" + payload = self._build_payload( + name=name, + description=description, + color=color, + is_default=is_default, + ) + return self._client._put(f"/v1/profiles/{profile_id}", data=payload) + + def delete_profile(self, profile_id: str) -> dict[str, Any]: + """Delete profile""" + return self._client._delete(f"/v1/profiles/{profile_id}") + + async def alist_profiles( + self, *, include_over_limit: bool | None = False + ) -> dict[str, Any]: + """List profiles (async)""" + params = self._build_params( + include_over_limit=include_over_limit, + ) + return await self._client._aget("/v1/profiles", params=params) + + async def acreate_profile( + self, name: str, *, description: str | None = None, color: str | None = None + ) -> dict[str, Any]: + """Create profile (async)""" + payload = self._build_payload( + name=name, + description=description, + color=color, + ) + return await self._client._apost("/v1/profiles", data=payload) + + async def aget_profile(self, profile_id: str) -> dict[str, Any]: + """Get profile (async)""" + return await self._client._aget(f"/v1/profiles/{profile_id}") + + async def aupdate_profile( + self, + profile_id: str, + *, + name: str | None = None, + description: str | None = None, + color: str | None = None, + is_default: bool | None = None, + ) -> dict[str, Any]: + """Update profile (async)""" + payload = self._build_payload( + name=name, + description=description, + color=color, + is_default=is_default, + ) + return await self._client._aput(f"/v1/profiles/{profile_id}", data=payload) + + async def adelete_profile(self, profile_id: str) -> dict[str, Any]: + """Delete profile (async)""" + return await self._client._adelete(f"/v1/profiles/{profile_id}") diff --git a/src/late/resources/_generated/queue.py b/src/late/resources/_generated/queue.py new file mode 100644 index 0000000..158cb82 --- /dev/null +++ b/src/late/resources/_generated/queue.py @@ -0,0 +1,219 @@ +""" +Auto-generated queue resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class QueueResource: + """ + Manage posting queue and time slots. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_queue_slots( + self, profile_id: str, *, queue_id: str | None = None, all: str | None = None + ) -> dict[str, Any]: + """List schedules""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + all=all, + ) + return self._client._get("/v1/queue/slots", params=params) + + def create_queue_slot( + self, + profile_id: str, + name: str, + timezone: str, + slots: list[Any], + *, + active: bool | None = True, + ) -> dict[str, Any]: + """Create schedule""" + payload = self._build_payload( + profile_id=profile_id, + name=name, + timezone=timezone, + slots=slots, + active=active, + ) + return self._client._post("/v1/queue/slots", data=payload) + + def update_queue_slot( + self, + profile_id: str, + timezone: str, + slots: list[Any], + *, + queue_id: str | None = None, + name: str | None = None, + active: bool | None = True, + set_as_default: bool | None = None, + reshuffle_existing: bool | None = False, + ) -> dict[str, Any]: + """Update schedule""" + payload = self._build_payload( + profile_id=profile_id, + queue_id=queue_id, + name=name, + timezone=timezone, + slots=slots, + active=active, + set_as_default=set_as_default, + reshuffle_existing=reshuffle_existing, + ) + return self._client._put("/v1/queue/slots", data=payload) + + def delete_queue_slot(self, profile_id: str, queue_id: str) -> dict[str, Any]: + """Delete schedule""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + ) + return self._client._delete("/v1/queue/slots", params=params) + + def preview_queue( + self, profile_id: str, *, queue_id: str | None = None, count: int | None = 20 + ) -> dict[str, Any]: + """Preview upcoming slots""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + count=count, + ) + return self._client._get("/v1/queue/preview", params=params) + + def get_next_queue_slot( + self, profile_id: str, *, queue_id: str | None = None + ) -> dict[str, Any]: + """Get next available slot""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + ) + return self._client._get("/v1/queue/next-slot", params=params) + + async def alist_queue_slots( + self, profile_id: str, *, queue_id: str | None = None, all: str | None = None + ) -> dict[str, Any]: + """List schedules (async)""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + all=all, + ) + return await self._client._aget("/v1/queue/slots", params=params) + + async def acreate_queue_slot( + self, + profile_id: str, + name: str, + timezone: str, + slots: list[Any], + *, + active: bool | None = True, + ) -> dict[str, Any]: + """Create schedule (async)""" + payload = self._build_payload( + profile_id=profile_id, + name=name, + timezone=timezone, + slots=slots, + active=active, + ) + return await self._client._apost("/v1/queue/slots", data=payload) + + async def aupdate_queue_slot( + self, + profile_id: str, + timezone: str, + slots: list[Any], + *, + queue_id: str | None = None, + name: str | None = None, + active: bool | None = True, + set_as_default: bool | None = None, + reshuffle_existing: bool | None = False, + ) -> dict[str, Any]: + """Update schedule (async)""" + payload = self._build_payload( + profile_id=profile_id, + queue_id=queue_id, + name=name, + timezone=timezone, + slots=slots, + active=active, + set_as_default=set_as_default, + reshuffle_existing=reshuffle_existing, + ) + return await self._client._aput("/v1/queue/slots", data=payload) + + async def adelete_queue_slot( + self, profile_id: str, queue_id: str + ) -> dict[str, Any]: + """Delete schedule (async)""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + ) + return await self._client._adelete("/v1/queue/slots", params=params) + + async def apreview_queue( + self, profile_id: str, *, queue_id: str | None = None, count: int | None = 20 + ) -> dict[str, Any]: + """Preview upcoming slots (async)""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + count=count, + ) + return await self._client._aget("/v1/queue/preview", params=params) + + async def aget_next_queue_slot( + self, profile_id: str, *, queue_id: str | None = None + ) -> dict[str, Any]: + """Get next available slot (async)""" + params = self._build_params( + profile_id=profile_id, + queue_id=queue_id, + ) + return await self._client._aget("/v1/queue/next-slot", params=params) diff --git a/src/late/resources/_generated/reddit.py b/src/late/resources/_generated/reddit.py new file mode 100644 index 0000000..318c48c --- /dev/null +++ b/src/late/resources/_generated/reddit.py @@ -0,0 +1,137 @@ +""" +Auto-generated reddit resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class RedditResource: + """ + Reddit search and feed. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def search_reddit( + self, + account_id: str, + q: str, + *, + subreddit: str | None = None, + restrict_sr: str | None = None, + sort: str | None = "new", + limit: int | None = 25, + after: str | None = None, + ) -> dict[str, Any]: + """Search posts""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + q=q, + restrict_sr=restrict_sr, + sort=sort, + limit=limit, + after=after, + ) + return self._client._get("/v1/reddit/search", params=params) + + def get_reddit_feed( + self, + account_id: str, + *, + subreddit: str | None = None, + sort: str | None = "hot", + limit: int | None = 25, + after: str | None = None, + t: str | None = None, + ) -> dict[str, Any]: + """Get subreddit feed""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + sort=sort, + limit=limit, + after=after, + t=t, + ) + return self._client._get("/v1/reddit/feed", params=params) + + async def asearch_reddit( + self, + account_id: str, + q: str, + *, + subreddit: str | None = None, + restrict_sr: str | None = None, + sort: str | None = "new", + limit: int | None = 25, + after: str | None = None, + ) -> dict[str, Any]: + """Search posts (async)""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + q=q, + restrict_sr=restrict_sr, + sort=sort, + limit=limit, + after=after, + ) + return await self._client._aget("/v1/reddit/search", params=params) + + async def aget_reddit_feed( + self, + account_id: str, + *, + subreddit: str | None = None, + sort: str | None = "hot", + limit: int | None = 25, + after: str | None = None, + t: str | None = None, + ) -> dict[str, Any]: + """Get subreddit feed (async)""" + params = self._build_params( + account_id=account_id, + subreddit=subreddit, + sort=sort, + limit=limit, + after=after, + t=t, + ) + return await self._client._aget("/v1/reddit/feed", params=params) diff --git a/src/late/resources/_generated/reviews.py b/src/late/resources/_generated/reviews.py new file mode 100644 index 0000000..8d37ea2 --- /dev/null +++ b/src/late/resources/_generated/reviews.py @@ -0,0 +1,141 @@ +""" +Auto-generated reviews resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class ReviewsResource: + """ + reviews operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_inbox_reviews( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_rating: int | None = None, + max_rating: int | None = None, + has_reply: bool | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 25, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List reviews""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_rating=min_rating, + max_rating=max_rating, + has_reply=has_reply, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return self._client._get("/v1/inbox/reviews", params=params) + + def reply_to_inbox_review( + self, review_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Reply to review""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return self._client._post(f"/v1/inbox/reviews/{review_id}/reply", data=payload) + + def delete_inbox_review_reply( + self, review_id: str, account_id: str + ) -> dict[str, Any]: + """Delete review reply""" + return self._client._delete(f"/v1/inbox/reviews/{review_id}/reply") + + async def alist_inbox_reviews( + self, + *, + profile_id: str | None = None, + platform: str | None = None, + min_rating: int | None = None, + max_rating: int | None = None, + has_reply: bool | None = None, + sort_by: str | None = "date", + sort_order: str | None = "desc", + limit: int | None = 25, + cursor: str | None = None, + account_id: str | None = None, + ) -> dict[str, Any]: + """List reviews (async)""" + params = self._build_params( + profile_id=profile_id, + platform=platform, + min_rating=min_rating, + max_rating=max_rating, + has_reply=has_reply, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + account_id=account_id, + ) + return await self._client._aget("/v1/inbox/reviews", params=params) + + async def areply_to_inbox_review( + self, review_id: str, account_id: str, message: str + ) -> dict[str, Any]: + """Reply to review (async)""" + payload = self._build_payload( + account_id=account_id, + message=message, + ) + return await self._client._apost( + f"/v1/inbox/reviews/{review_id}/reply", data=payload + ) + + async def adelete_inbox_review_reply( + self, review_id: str, account_id: str + ) -> dict[str, Any]: + """Delete review reply (async)""" + return await self._client._adelete(f"/v1/inbox/reviews/{review_id}/reply") diff --git a/src/late/resources/_generated/tools.py b/src/late/resources/_generated/tools.py new file mode 100644 index 0000000..89293d7 --- /dev/null +++ b/src/late/resources/_generated/tools.py @@ -0,0 +1,223 @@ +""" +Auto-generated tools resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class ToolsResource: + """ + Media download and utility tools. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def download_you_tube_video( + self, + url: str, + *, + action: str | None = "download", + format: str | None = "video", + quality: str | None = "hd", + format_id: str | None = None, + ) -> dict[str, Any]: + """Download YouTube video""" + params = self._build_params( + url=url, + action=action, + format=format, + quality=quality, + format_id=format_id, + ) + return self._client._get("/v1/tools/youtube/download", params=params) + + def get_you_tube_transcript( + self, url: str, *, lang: str | None = "en" + ) -> dict[str, Any]: + """Get YouTube transcript""" + params = self._build_params( + url=url, + lang=lang, + ) + return self._client._get("/v1/tools/youtube/transcript", params=params) + + def download_instagram_media(self, url: str) -> dict[str, Any]: + """Download Instagram media""" + params = self._build_params( + url=url, + ) + return self._client._get("/v1/tools/instagram/download", params=params) + + def check_instagram_hashtags(self, hashtags: list[str]) -> dict[str, Any]: + """Check IG hashtag bans""" + payload = self._build_payload( + hashtags=hashtags, + ) + return self._client._post("/v1/tools/instagram/hashtag-checker", data=payload) + + def download_tik_tok_video( + self, url: str, *, action: str | None = "download", format_id: str | None = None + ) -> dict[str, Any]: + """Download TikTok video""" + params = self._build_params( + url=url, + action=action, + format_id=format_id, + ) + return self._client._get("/v1/tools/tiktok/download", params=params) + + def download_twitter_media( + self, url: str, *, action: str | None = "download", format_id: str | None = None + ) -> dict[str, Any]: + """Download Twitter/X media""" + params = self._build_params( + url=url, + action=action, + format_id=format_id, + ) + return self._client._get("/v1/tools/twitter/download", params=params) + + def download_facebook_video(self, url: str) -> dict[str, Any]: + """Download Facebook video""" + params = self._build_params( + url=url, + ) + return self._client._get("/v1/tools/facebook/download", params=params) + + def download_linked_in_video(self, url: str) -> dict[str, Any]: + """Download LinkedIn video""" + params = self._build_params( + url=url, + ) + return self._client._get("/v1/tools/linkedin/download", params=params) + + def download_bluesky_media(self, url: str) -> dict[str, Any]: + """Download Bluesky media""" + params = self._build_params( + url=url, + ) + return self._client._get("/v1/tools/bluesky/download", params=params) + + async def adownload_you_tube_video( + self, + url: str, + *, + action: str | None = "download", + format: str | None = "video", + quality: str | None = "hd", + format_id: str | None = None, + ) -> dict[str, Any]: + """Download YouTube video (async)""" + params = self._build_params( + url=url, + action=action, + format=format, + quality=quality, + format_id=format_id, + ) + return await self._client._aget("/v1/tools/youtube/download", params=params) + + async def aget_you_tube_transcript( + self, url: str, *, lang: str | None = "en" + ) -> dict[str, Any]: + """Get YouTube transcript (async)""" + params = self._build_params( + url=url, + lang=lang, + ) + return await self._client._aget("/v1/tools/youtube/transcript", params=params) + + async def adownload_instagram_media(self, url: str) -> dict[str, Any]: + """Download Instagram media (async)""" + params = self._build_params( + url=url, + ) + return await self._client._aget("/v1/tools/instagram/download", params=params) + + async def acheck_instagram_hashtags(self, hashtags: list[str]) -> dict[str, Any]: + """Check IG hashtag bans (async)""" + payload = self._build_payload( + hashtags=hashtags, + ) + return await self._client._apost( + "/v1/tools/instagram/hashtag-checker", data=payload + ) + + async def adownload_tik_tok_video( + self, url: str, *, action: str | None = "download", format_id: str | None = None + ) -> dict[str, Any]: + """Download TikTok video (async)""" + params = self._build_params( + url=url, + action=action, + format_id=format_id, + ) + return await self._client._aget("/v1/tools/tiktok/download", params=params) + + async def adownload_twitter_media( + self, url: str, *, action: str | None = "download", format_id: str | None = None + ) -> dict[str, Any]: + """Download Twitter/X media (async)""" + params = self._build_params( + url=url, + action=action, + format_id=format_id, + ) + return await self._client._aget("/v1/tools/twitter/download", params=params) + + async def adownload_facebook_video(self, url: str) -> dict[str, Any]: + """Download Facebook video (async)""" + params = self._build_params( + url=url, + ) + return await self._client._aget("/v1/tools/facebook/download", params=params) + + async def adownload_linked_in_video(self, url: str) -> dict[str, Any]: + """Download LinkedIn video (async)""" + params = self._build_params( + url=url, + ) + return await self._client._aget("/v1/tools/linkedin/download", params=params) + + async def adownload_bluesky_media(self, url: str) -> dict[str, Any]: + """Download Bluesky media (async)""" + params = self._build_params( + url=url, + ) + return await self._client._aget("/v1/tools/bluesky/download", params=params) diff --git a/src/late/resources/_generated/twitter_engagement.py b/src/late/resources/_generated/twitter_engagement.py new file mode 100644 index 0000000..d908215 --- /dev/null +++ b/src/late/resources/_generated/twitter_engagement.py @@ -0,0 +1,149 @@ +""" +Auto-generated twitter_engagement resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class TwitterEngagementResource: + """ + twitter_engagement operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def retweet_post(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Retweet a post""" + payload = self._build_payload( + account_id=account_id, + tweet_id=tweet_id, + ) + return self._client._post("/v1/twitter/retweet", data=payload) + + def undo_retweet(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Undo retweet""" + params = self._build_params( + account_id=account_id, + tweet_id=tweet_id, + ) + return self._client._delete("/v1/twitter/retweet", params=params) + + def bookmark_post(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Bookmark a tweet""" + payload = self._build_payload( + account_id=account_id, + tweet_id=tweet_id, + ) + return self._client._post("/v1/twitter/bookmark", data=payload) + + def remove_bookmark(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Remove bookmark""" + params = self._build_params( + account_id=account_id, + tweet_id=tweet_id, + ) + return self._client._delete("/v1/twitter/bookmark", params=params) + + def follow_user(self, account_id: str, target_user_id: str) -> dict[str, Any]: + """Follow a user""" + payload = self._build_payload( + account_id=account_id, + target_user_id=target_user_id, + ) + return self._client._post("/v1/twitter/follow", data=payload) + + def unfollow_user(self, account_id: str, target_user_id: str) -> dict[str, Any]: + """Unfollow a user""" + params = self._build_params( + account_id=account_id, + target_user_id=target_user_id, + ) + return self._client._delete("/v1/twitter/follow", params=params) + + async def aretweet_post(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Retweet a post (async)""" + payload = self._build_payload( + account_id=account_id, + tweet_id=tweet_id, + ) + return await self._client._apost("/v1/twitter/retweet", data=payload) + + async def aundo_retweet(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Undo retweet (async)""" + params = self._build_params( + account_id=account_id, + tweet_id=tweet_id, + ) + return await self._client._adelete("/v1/twitter/retweet", params=params) + + async def abookmark_post(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Bookmark a tweet (async)""" + payload = self._build_payload( + account_id=account_id, + tweet_id=tweet_id, + ) + return await self._client._apost("/v1/twitter/bookmark", data=payload) + + async def aremove_bookmark(self, account_id: str, tweet_id: str) -> dict[str, Any]: + """Remove bookmark (async)""" + params = self._build_params( + account_id=account_id, + tweet_id=tweet_id, + ) + return await self._client._adelete("/v1/twitter/bookmark", params=params) + + async def afollow_user( + self, account_id: str, target_user_id: str + ) -> dict[str, Any]: + """Follow a user (async)""" + payload = self._build_payload( + account_id=account_id, + target_user_id=target_user_id, + ) + return await self._client._apost("/v1/twitter/follow", data=payload) + + async def aunfollow_user( + self, account_id: str, target_user_id: str + ) -> dict[str, Any]: + """Unfollow a user (async)""" + params = self._build_params( + account_id=account_id, + target_user_id=target_user_id, + ) + return await self._client._adelete("/v1/twitter/follow", params=params) diff --git a/src/late/resources/_generated/usage.py b/src/late/resources/_generated/usage.py new file mode 100644 index 0000000..d5aa576 --- /dev/null +++ b/src/late/resources/_generated/usage.py @@ -0,0 +1,57 @@ +""" +Auto-generated usage resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class UsageResource: + """ + Get usage statistics. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_usage_stats(self) -> dict[str, Any]: + """Get plan and usage stats""" + return self._client._get("/v1/usage-stats") + + async def aget_usage_stats(self) -> dict[str, Any]: + """Get plan and usage stats (async)""" + return await self._client._aget("/v1/usage-stats") diff --git a/src/late/resources/_generated/users.py b/src/late/resources/_generated/users.py new file mode 100644 index 0000000..9c32545 --- /dev/null +++ b/src/late/resources/_generated/users.py @@ -0,0 +1,65 @@ +""" +Auto-generated users resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class UsersResource: + """ + User management. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def list_users(self) -> dict[str, Any]: + """List users""" + return self._client._get("/v1/users") + + def get_user(self, user_id: str) -> dict[str, Any]: + """Get user""" + return self._client._get(f"/v1/users/{user_id}") + + async def alist_users(self) -> dict[str, Any]: + """List users (async)""" + return await self._client._aget("/v1/users") + + async def aget_user(self, user_id: str) -> dict[str, Any]: + """Get user (async)""" + return await self._client._aget(f"/v1/users/{user_id}") diff --git a/src/late/resources/_generated/validate.py b/src/late/resources/_generated/validate.py new file mode 100644 index 0000000..988f4f4 --- /dev/null +++ b/src/late/resources/_generated/validate.py @@ -0,0 +1,121 @@ +""" +Auto-generated validate resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class ValidateResource: + """ + validate operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def validate_post_length(self, text: str) -> dict[str, Any]: + """Validate post character count""" + payload = self._build_payload( + text=text, + ) + return self._client._post("/v1/tools/validate/post-length", data=payload) + + def validate_post( + self, + platforms: list[dict[str, Any]], + *, + content: str | None = None, + media_items: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Validate post content""" + payload = self._build_payload( + content=content, + platforms=platforms, + media_items=media_items, + ) + return self._client._post("/v1/tools/validate/post", data=payload) + + def validate_media(self, url: str) -> dict[str, Any]: + """Validate media URL""" + payload = self._build_payload( + url=url, + ) + return self._client._post("/v1/tools/validate/media", data=payload) + + def validate_subreddit(self, name: str) -> dict[str, Any]: + """Check subreddit existence""" + params = self._build_params( + name=name, + ) + return self._client._get("/v1/tools/validate/subreddit", params=params) + + async def avalidate_post_length(self, text: str) -> dict[str, Any]: + """Validate post character count (async)""" + payload = self._build_payload( + text=text, + ) + return await self._client._apost("/v1/tools/validate/post-length", data=payload) + + async def avalidate_post( + self, + platforms: list[dict[str, Any]], + *, + content: str | None = None, + media_items: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Validate post content (async)""" + payload = self._build_payload( + content=content, + platforms=platforms, + media_items=media_items, + ) + return await self._client._apost("/v1/tools/validate/post", data=payload) + + async def avalidate_media(self, url: str) -> dict[str, Any]: + """Validate media URL (async)""" + payload = self._build_payload( + url=url, + ) + return await self._client._apost("/v1/tools/validate/media", data=payload) + + async def avalidate_subreddit(self, name: str) -> dict[str, Any]: + """Check subreddit existence (async)""" + params = self._build_params( + name=name, + ) + return await self._client._aget("/v1/tools/validate/subreddit", params=params) diff --git a/src/late/resources/_generated/webhooks.py b/src/late/resources/_generated/webhooks.py new file mode 100644 index 0000000..6994891 --- /dev/null +++ b/src/late/resources/_generated/webhooks.py @@ -0,0 +1,207 @@ +""" +Auto-generated webhooks resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class WebhooksResource: + """ + Configure event webhooks. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_webhook_settings(self) -> dict[str, Any]: + """List webhooks""" + return self._client._get("/v1/webhooks/settings") + + def create_webhook_settings( + self, + *, + name: str | None = None, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + custom_headers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Create webhook""" + payload = self._build_payload( + name=name, + url=url, + secret=secret, + events=events, + is_active=is_active, + custom_headers=custom_headers, + ) + return self._client._post("/v1/webhooks/settings", data=payload) + + def update_webhook_settings( + self, + _id: str, + *, + name: str | None = None, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + custom_headers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Update webhook""" + payload = self._build_payload( + _id=_id, + name=name, + url=url, + secret=secret, + events=events, + is_active=is_active, + custom_headers=custom_headers, + ) + return self._client._put("/v1/webhooks/settings", data=payload) + + def delete_webhook_settings(self, id: str) -> dict[str, Any]: + """Delete webhook""" + params = self._build_params( + id=id, + ) + return self._client._delete("/v1/webhooks/settings", params=params) + + def test_webhook(self, webhook_id: str) -> dict[str, Any]: + """Send test webhook""" + payload = self._build_payload( + webhook_id=webhook_id, + ) + return self._client._post("/v1/webhooks/test", data=payload) + + def get_webhook_logs( + self, + *, + limit: int | None = 50, + status: str | None = None, + event: str | None = None, + webhook_id: str | None = None, + ) -> dict[str, Any]: + """Get delivery logs""" + params = self._build_params( + limit=limit, + status=status, + event=event, + webhook_id=webhook_id, + ) + return self._client._get("/v1/webhooks/logs", params=params) + + async def aget_webhook_settings(self) -> dict[str, Any]: + """List webhooks (async)""" + return await self._client._aget("/v1/webhooks/settings") + + async def acreate_webhook_settings( + self, + *, + name: str | None = None, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + custom_headers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Create webhook (async)""" + payload = self._build_payload( + name=name, + url=url, + secret=secret, + events=events, + is_active=is_active, + custom_headers=custom_headers, + ) + return await self._client._apost("/v1/webhooks/settings", data=payload) + + async def aupdate_webhook_settings( + self, + _id: str, + *, + name: str | None = None, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + custom_headers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Update webhook (async)""" + payload = self._build_payload( + _id=_id, + name=name, + url=url, + secret=secret, + events=events, + is_active=is_active, + custom_headers=custom_headers, + ) + return await self._client._aput("/v1/webhooks/settings", data=payload) + + async def adelete_webhook_settings(self, id: str) -> dict[str, Any]: + """Delete webhook (async)""" + params = self._build_params( + id=id, + ) + return await self._client._adelete("/v1/webhooks/settings", params=params) + + async def atest_webhook(self, webhook_id: str) -> dict[str, Any]: + """Send test webhook (async)""" + payload = self._build_payload( + webhook_id=webhook_id, + ) + return await self._client._apost("/v1/webhooks/test", data=payload) + + async def aget_webhook_logs( + self, + *, + limit: int | None = 50, + status: str | None = None, + event: str | None = None, + webhook_id: str | None = None, + ) -> dict[str, Any]: + """Get delivery logs (async)""" + params = self._build_params( + limit=limit, + status=status, + event=event, + webhook_id=webhook_id, + ) + return await self._client._aget("/v1/webhooks/logs", params=params) diff --git a/src/late/resources/_generated/whatsapp.py b/src/late/resources/_generated/whatsapp.py new file mode 100644 index 0000000..c3a417a --- /dev/null +++ b/src/late/resources/_generated/whatsapp.py @@ -0,0 +1,837 @@ +""" +Auto-generated whatsapp resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datetime import datetime + + from ..client.base import BaseClient + + +class WhatsappResource: + """ + whatsapp operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def send_whats_app_bulk( + self, + account_id: str, + recipients: list[dict[str, Any]], + template: dict[str, Any], + ) -> dict[str, Any]: + """Bulk send template messages""" + payload = self._build_payload( + account_id=account_id, + recipients=recipients, + template=template, + ) + return self._client._post("/v1/whatsapp/bulk", data=payload) + + def get_whats_app_contacts( + self, + account_id: str, + *, + search: str | None = None, + tag: str | None = None, + group: str | None = None, + opted_in: str | None = None, + limit: int | None = 50, + skip: int | None = 0, + ) -> dict[str, Any]: + """List contacts""" + params = self._build_params( + account_id=account_id, + search=search, + tag=tag, + group=group, + opted_in=opted_in, + limit=limit, + skip=skip, + ) + return self._client._get("/v1/whatsapp/contacts", params=params) + + def create_whats_app_contact( + self, + account_id: str, + phone: str, + name: str, + *, + email: str | None = None, + company: str | None = None, + tags: list[str] | None = None, + groups: list[str] | None = None, + is_opted_in: bool | None = True, + custom_fields: dict[str, Any] | None = None, + notes: str | None = None, + ) -> dict[str, Any]: + """Create contact""" + payload = self._build_payload( + account_id=account_id, + phone=phone, + name=name, + email=email, + company=company, + tags=tags, + groups=groups, + is_opted_in=is_opted_in, + custom_fields=custom_fields, + notes=notes, + ) + return self._client._post("/v1/whatsapp/contacts", data=payload) + + def get_whats_app_contact(self, contact_id: str) -> dict[str, Any]: + """Get contact""" + return self._client._get(f"/v1/whatsapp/contacts/{contact_id}") + + def update_whats_app_contact( + self, + contact_id: str, + *, + name: str | None = None, + email: str | None = None, + company: str | None = None, + tags: list[str] | None = None, + groups: list[str] | None = None, + is_opted_in: bool | None = None, + is_blocked: bool | None = None, + custom_fields: dict[str, Any] | None = None, + notes: str | None = None, + ) -> dict[str, Any]: + """Update contact""" + payload = self._build_payload( + name=name, + email=email, + company=company, + tags=tags, + groups=groups, + is_opted_in=is_opted_in, + is_blocked=is_blocked, + custom_fields=custom_fields, + notes=notes, + ) + return self._client._put(f"/v1/whatsapp/contacts/{contact_id}", data=payload) + + def delete_whats_app_contact(self, contact_id: str) -> dict[str, Any]: + """Delete contact""" + return self._client._delete(f"/v1/whatsapp/contacts/{contact_id}") + + def import_whats_app_contacts( + self, + account_id: str, + contacts: list[dict[str, Any]], + *, + default_tags: list[str] | None = None, + default_groups: list[str] | None = None, + skip_duplicates: bool | None = True, + ) -> dict[str, Any]: + """Bulk import contacts""" + payload = self._build_payload( + account_id=account_id, + contacts=contacts, + default_tags=default_tags, + default_groups=default_groups, + skip_duplicates=skip_duplicates, + ) + return self._client._post("/v1/whatsapp/contacts/import", data=payload) + + def bulk_update_whats_app_contacts( + self, + action: str, + contact_ids: list[str], + *, + tags: list[str] | None = None, + groups: list[str] | None = None, + ) -> dict[str, Any]: + """Bulk update contacts""" + payload = self._build_payload( + action=action, + contact_ids=contact_ids, + tags=tags, + groups=groups, + ) + return self._client._post("/v1/whatsapp/contacts/bulk", data=payload) + + def bulk_delete_whats_app_contacts(self, contact_ids: list[str]) -> dict[str, Any]: + """Bulk delete contacts""" + return self._client._delete("/v1/whatsapp/contacts/bulk") + + def get_whats_app_groups(self, account_id: str) -> dict[str, Any]: + """List contact groups""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get("/v1/whatsapp/groups", params=params) + + def rename_whats_app_group( + self, account_id: str, old_name: str, new_name: str + ) -> dict[str, Any]: + """Rename group""" + payload = self._build_payload( + account_id=account_id, + old_name=old_name, + new_name=new_name, + ) + return self._client._post("/v1/whatsapp/groups", data=payload) + + def delete_whats_app_group( + self, account_id: str, group_name: str + ) -> dict[str, Any]: + """Delete group""" + return self._client._delete("/v1/whatsapp/groups") + + def get_whats_app_templates(self, account_id: str) -> dict[str, Any]: + """List templates""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get("/v1/whatsapp/templates", params=params) + + def create_whats_app_template( + self, + account_id: str, + name: str, + category: str, + language: str, + *, + components: list[dict[str, Any]] | None = None, + library_template_name: str | None = None, + library_template_body_inputs: dict[str, Any] | None = None, + library_template_button_inputs: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Create template""" + payload = self._build_payload( + account_id=account_id, + name=name, + category=category, + language=language, + components=components, + library_template_name=library_template_name, + library_template_body_inputs=library_template_body_inputs, + library_template_button_inputs=library_template_button_inputs, + ) + return self._client._post("/v1/whatsapp/templates", data=payload) + + def get_whats_app_template( + self, template_name: str, account_id: str + ) -> dict[str, Any]: + """Get template""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get( + f"/v1/whatsapp/templates/{template_name}", params=params + ) + + def update_whats_app_template( + self, template_name: str, account_id: str, components: list[dict[str, Any]] + ) -> dict[str, Any]: + """Update template""" + payload = self._build_payload( + account_id=account_id, + components=components, + ) + return self._client._patch( + f"/v1/whatsapp/templates/{template_name}", data=payload + ) + + def delete_whats_app_template( + self, template_name: str, account_id: str + ) -> dict[str, Any]: + """Delete template""" + params = self._build_params( + account_id=account_id, + ) + return self._client._delete( + f"/v1/whatsapp/templates/{template_name}", params=params + ) + + def get_whats_app_broadcasts( + self, + account_id: str, + *, + status: str | None = None, + limit: int | None = 50, + skip: int | None = 0, + ) -> dict[str, Any]: + """List broadcasts""" + params = self._build_params( + account_id=account_id, + status=status, + limit=limit, + skip=skip, + ) + return self._client._get("/v1/whatsapp/broadcasts", params=params) + + def create_whats_app_broadcast( + self, + account_id: str, + name: str, + template: dict[str, Any], + *, + description: str | None = None, + recipients: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Create broadcast""" + payload = self._build_payload( + account_id=account_id, + name=name, + description=description, + template=template, + recipients=recipients, + ) + return self._client._post("/v1/whatsapp/broadcasts", data=payload) + + def get_whats_app_broadcast(self, broadcast_id: str) -> dict[str, Any]: + """Get broadcast""" + return self._client._get(f"/v1/whatsapp/broadcasts/{broadcast_id}") + + def delete_whats_app_broadcast(self, broadcast_id: str) -> dict[str, Any]: + """Delete broadcast""" + return self._client._delete(f"/v1/whatsapp/broadcasts/{broadcast_id}") + + def send_whats_app_broadcast(self, broadcast_id: str) -> dict[str, Any]: + """Send broadcast""" + return self._client._post(f"/v1/whatsapp/broadcasts/{broadcast_id}/send") + + def schedule_whats_app_broadcast( + self, broadcast_id: str, scheduled_at: datetime | str + ) -> dict[str, Any]: + """Schedule broadcast""" + payload = self._build_payload( + scheduled_at=scheduled_at, + ) + return self._client._post( + f"/v1/whatsapp/broadcasts/{broadcast_id}/schedule", data=payload + ) + + def cancel_whats_app_broadcast_schedule(self, broadcast_id: str) -> dict[str, Any]: + """Cancel scheduled broadcast""" + return self._client._delete(f"/v1/whatsapp/broadcasts/{broadcast_id}/schedule") + + def get_whats_app_broadcast_recipients( + self, + broadcast_id: str, + *, + status: str | None = None, + limit: int | None = 100, + skip: int | None = 0, + ) -> dict[str, Any]: + """List recipients""" + params = self._build_params( + status=status, + limit=limit, + skip=skip, + ) + return self._client._get( + f"/v1/whatsapp/broadcasts/{broadcast_id}/recipients", params=params + ) + + def add_whats_app_broadcast_recipients( + self, broadcast_id: str, recipients: list[dict[str, Any]] + ) -> dict[str, Any]: + """Add recipients""" + payload = self._build_payload( + recipients=recipients, + ) + return self._client._patch( + f"/v1/whatsapp/broadcasts/{broadcast_id}/recipients", data=payload + ) + + def remove_whats_app_broadcast_recipients( + self, broadcast_id: str, phones: list[str] + ) -> dict[str, Any]: + """Remove recipients""" + return self._client._delete( + f"/v1/whatsapp/broadcasts/{broadcast_id}/recipients" + ) + + def get_whats_app_business_profile(self, account_id: str) -> dict[str, Any]: + """Get business profile""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get("/v1/whatsapp/business-profile", params=params) + + def update_whats_app_business_profile( + self, + account_id: str, + *, + about: str | None = None, + address: str | None = None, + description: str | None = None, + email: str | None = None, + websites: list[str] | None = None, + vertical: str | None = None, + profile_picture_handle: str | None = None, + ) -> dict[str, Any]: + """Update business profile""" + payload = self._build_payload( + account_id=account_id, + about=about, + address=address, + description=description, + email=email, + websites=websites, + vertical=vertical, + profile_picture_handle=profile_picture_handle, + ) + return self._client._post("/v1/whatsapp/business-profile", data=payload) + + def upload_whats_app_profile_photo(self) -> dict[str, Any]: + """Upload profile picture""" + return self._client._post("/v1/whatsapp/business-profile/photo") + + def get_whats_app_display_name(self, account_id: str) -> dict[str, Any]: + """Get display name and review status""" + params = self._build_params( + account_id=account_id, + ) + return self._client._get( + "/v1/whatsapp/business-profile/display-name", params=params + ) + + def update_whats_app_display_name( + self, account_id: str, display_name: str + ) -> dict[str, Any]: + """Request display name change""" + payload = self._build_payload( + account_id=account_id, + display_name=display_name, + ) + return self._client._post( + "/v1/whatsapp/business-profile/display-name", data=payload + ) + + async def asend_whats_app_bulk( + self, + account_id: str, + recipients: list[dict[str, Any]], + template: dict[str, Any], + ) -> dict[str, Any]: + """Bulk send template messages (async)""" + payload = self._build_payload( + account_id=account_id, + recipients=recipients, + template=template, + ) + return await self._client._apost("/v1/whatsapp/bulk", data=payload) + + async def aget_whats_app_contacts( + self, + account_id: str, + *, + search: str | None = None, + tag: str | None = None, + group: str | None = None, + opted_in: str | None = None, + limit: int | None = 50, + skip: int | None = 0, + ) -> dict[str, Any]: + """List contacts (async)""" + params = self._build_params( + account_id=account_id, + search=search, + tag=tag, + group=group, + opted_in=opted_in, + limit=limit, + skip=skip, + ) + return await self._client._aget("/v1/whatsapp/contacts", params=params) + + async def acreate_whats_app_contact( + self, + account_id: str, + phone: str, + name: str, + *, + email: str | None = None, + company: str | None = None, + tags: list[str] | None = None, + groups: list[str] | None = None, + is_opted_in: bool | None = True, + custom_fields: dict[str, Any] | None = None, + notes: str | None = None, + ) -> dict[str, Any]: + """Create contact (async)""" + payload = self._build_payload( + account_id=account_id, + phone=phone, + name=name, + email=email, + company=company, + tags=tags, + groups=groups, + is_opted_in=is_opted_in, + custom_fields=custom_fields, + notes=notes, + ) + return await self._client._apost("/v1/whatsapp/contacts", data=payload) + + async def aget_whats_app_contact(self, contact_id: str) -> dict[str, Any]: + """Get contact (async)""" + return await self._client._aget(f"/v1/whatsapp/contacts/{contact_id}") + + async def aupdate_whats_app_contact( + self, + contact_id: str, + *, + name: str | None = None, + email: str | None = None, + company: str | None = None, + tags: list[str] | None = None, + groups: list[str] | None = None, + is_opted_in: bool | None = None, + is_blocked: bool | None = None, + custom_fields: dict[str, Any] | None = None, + notes: str | None = None, + ) -> dict[str, Any]: + """Update contact (async)""" + payload = self._build_payload( + name=name, + email=email, + company=company, + tags=tags, + groups=groups, + is_opted_in=is_opted_in, + is_blocked=is_blocked, + custom_fields=custom_fields, + notes=notes, + ) + return await self._client._aput( + f"/v1/whatsapp/contacts/{contact_id}", data=payload + ) + + async def adelete_whats_app_contact(self, contact_id: str) -> dict[str, Any]: + """Delete contact (async)""" + return await self._client._adelete(f"/v1/whatsapp/contacts/{contact_id}") + + async def aimport_whats_app_contacts( + self, + account_id: str, + contacts: list[dict[str, Any]], + *, + default_tags: list[str] | None = None, + default_groups: list[str] | None = None, + skip_duplicates: bool | None = True, + ) -> dict[str, Any]: + """Bulk import contacts (async)""" + payload = self._build_payload( + account_id=account_id, + contacts=contacts, + default_tags=default_tags, + default_groups=default_groups, + skip_duplicates=skip_duplicates, + ) + return await self._client._apost("/v1/whatsapp/contacts/import", data=payload) + + async def abulk_update_whats_app_contacts( + self, + action: str, + contact_ids: list[str], + *, + tags: list[str] | None = None, + groups: list[str] | None = None, + ) -> dict[str, Any]: + """Bulk update contacts (async)""" + payload = self._build_payload( + action=action, + contact_ids=contact_ids, + tags=tags, + groups=groups, + ) + return await self._client._apost("/v1/whatsapp/contacts/bulk", data=payload) + + async def abulk_delete_whats_app_contacts( + self, contact_ids: list[str] + ) -> dict[str, Any]: + """Bulk delete contacts (async)""" + return await self._client._adelete("/v1/whatsapp/contacts/bulk") + + async def aget_whats_app_groups(self, account_id: str) -> dict[str, Any]: + """List contact groups (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget("/v1/whatsapp/groups", params=params) + + async def arename_whats_app_group( + self, account_id: str, old_name: str, new_name: str + ) -> dict[str, Any]: + """Rename group (async)""" + payload = self._build_payload( + account_id=account_id, + old_name=old_name, + new_name=new_name, + ) + return await self._client._apost("/v1/whatsapp/groups", data=payload) + + async def adelete_whats_app_group( + self, account_id: str, group_name: str + ) -> dict[str, Any]: + """Delete group (async)""" + return await self._client._adelete("/v1/whatsapp/groups") + + async def aget_whats_app_templates(self, account_id: str) -> dict[str, Any]: + """List templates (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget("/v1/whatsapp/templates", params=params) + + async def acreate_whats_app_template( + self, + account_id: str, + name: str, + category: str, + language: str, + *, + components: list[dict[str, Any]] | None = None, + library_template_name: str | None = None, + library_template_body_inputs: dict[str, Any] | None = None, + library_template_button_inputs: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Create template (async)""" + payload = self._build_payload( + account_id=account_id, + name=name, + category=category, + language=language, + components=components, + library_template_name=library_template_name, + library_template_body_inputs=library_template_body_inputs, + library_template_button_inputs=library_template_button_inputs, + ) + return await self._client._apost("/v1/whatsapp/templates", data=payload) + + async def aget_whats_app_template( + self, template_name: str, account_id: str + ) -> dict[str, Any]: + """Get template (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget( + f"/v1/whatsapp/templates/{template_name}", params=params + ) + + async def aupdate_whats_app_template( + self, template_name: str, account_id: str, components: list[dict[str, Any]] + ) -> dict[str, Any]: + """Update template (async)""" + payload = self._build_payload( + account_id=account_id, + components=components, + ) + return await self._client._apatch( + f"/v1/whatsapp/templates/{template_name}", data=payload + ) + + async def adelete_whats_app_template( + self, template_name: str, account_id: str + ) -> dict[str, Any]: + """Delete template (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._adelete( + f"/v1/whatsapp/templates/{template_name}", params=params + ) + + async def aget_whats_app_broadcasts( + self, + account_id: str, + *, + status: str | None = None, + limit: int | None = 50, + skip: int | None = 0, + ) -> dict[str, Any]: + """List broadcasts (async)""" + params = self._build_params( + account_id=account_id, + status=status, + limit=limit, + skip=skip, + ) + return await self._client._aget("/v1/whatsapp/broadcasts", params=params) + + async def acreate_whats_app_broadcast( + self, + account_id: str, + name: str, + template: dict[str, Any], + *, + description: str | None = None, + recipients: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Create broadcast (async)""" + payload = self._build_payload( + account_id=account_id, + name=name, + description=description, + template=template, + recipients=recipients, + ) + return await self._client._apost("/v1/whatsapp/broadcasts", data=payload) + + async def aget_whats_app_broadcast(self, broadcast_id: str) -> dict[str, Any]: + """Get broadcast (async)""" + return await self._client._aget(f"/v1/whatsapp/broadcasts/{broadcast_id}") + + async def adelete_whats_app_broadcast(self, broadcast_id: str) -> dict[str, Any]: + """Delete broadcast (async)""" + return await self._client._adelete(f"/v1/whatsapp/broadcasts/{broadcast_id}") + + async def asend_whats_app_broadcast(self, broadcast_id: str) -> dict[str, Any]: + """Send broadcast (async)""" + return await self._client._apost(f"/v1/whatsapp/broadcasts/{broadcast_id}/send") + + async def aschedule_whats_app_broadcast( + self, broadcast_id: str, scheduled_at: datetime | str + ) -> dict[str, Any]: + """Schedule broadcast (async)""" + payload = self._build_payload( + scheduled_at=scheduled_at, + ) + return await self._client._apost( + f"/v1/whatsapp/broadcasts/{broadcast_id}/schedule", data=payload + ) + + async def acancel_whats_app_broadcast_schedule( + self, broadcast_id: str + ) -> dict[str, Any]: + """Cancel scheduled broadcast (async)""" + return await self._client._adelete( + f"/v1/whatsapp/broadcasts/{broadcast_id}/schedule" + ) + + async def aget_whats_app_broadcast_recipients( + self, + broadcast_id: str, + *, + status: str | None = None, + limit: int | None = 100, + skip: int | None = 0, + ) -> dict[str, Any]: + """List recipients (async)""" + params = self._build_params( + status=status, + limit=limit, + skip=skip, + ) + return await self._client._aget( + f"/v1/whatsapp/broadcasts/{broadcast_id}/recipients", params=params + ) + + async def aadd_whats_app_broadcast_recipients( + self, broadcast_id: str, recipients: list[dict[str, Any]] + ) -> dict[str, Any]: + """Add recipients (async)""" + payload = self._build_payload( + recipients=recipients, + ) + return await self._client._apatch( + f"/v1/whatsapp/broadcasts/{broadcast_id}/recipients", data=payload + ) + + async def aremove_whats_app_broadcast_recipients( + self, broadcast_id: str, phones: list[str] + ) -> dict[str, Any]: + """Remove recipients (async)""" + return await self._client._adelete( + f"/v1/whatsapp/broadcasts/{broadcast_id}/recipients" + ) + + async def aget_whats_app_business_profile(self, account_id: str) -> dict[str, Any]: + """Get business profile (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget("/v1/whatsapp/business-profile", params=params) + + async def aupdate_whats_app_business_profile( + self, + account_id: str, + *, + about: str | None = None, + address: str | None = None, + description: str | None = None, + email: str | None = None, + websites: list[str] | None = None, + vertical: str | None = None, + profile_picture_handle: str | None = None, + ) -> dict[str, Any]: + """Update business profile (async)""" + payload = self._build_payload( + account_id=account_id, + about=about, + address=address, + description=description, + email=email, + websites=websites, + vertical=vertical, + profile_picture_handle=profile_picture_handle, + ) + return await self._client._apost("/v1/whatsapp/business-profile", data=payload) + + async def aupload_whats_app_profile_photo(self) -> dict[str, Any]: + """Upload profile picture (async)""" + return await self._client._apost("/v1/whatsapp/business-profile/photo") + + async def aget_whats_app_display_name(self, account_id: str) -> dict[str, Any]: + """Get display name and review status (async)""" + params = self._build_params( + account_id=account_id, + ) + return await self._client._aget( + "/v1/whatsapp/business-profile/display-name", params=params + ) + + async def aupdate_whats_app_display_name( + self, account_id: str, display_name: str + ) -> dict[str, Any]: + """Request display name change (async)""" + payload = self._build_payload( + account_id=account_id, + display_name=display_name, + ) + return await self._client._apost( + "/v1/whatsapp/business-profile/display-name", data=payload + ) diff --git a/src/late/resources/_generated/whatsapp_phone_numbers.py b/src/late/resources/_generated/whatsapp_phone_numbers.py new file mode 100644 index 0000000..136d137 --- /dev/null +++ b/src/late/resources/_generated/whatsapp_phone_numbers.py @@ -0,0 +1,105 @@ +""" +Auto-generated whatsapp_phone_numbers resource. + +DO NOT EDIT THIS FILE MANUALLY. +Run `python scripts/generate_resources.py` to regenerate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..client.base import BaseClient + + +class WhatsappPhoneNumbersResource: + """ + whatsapp_phone_numbers operations. + """ + + def __init__(self, client: BaseClient) -> None: + self._client = client + + def _build_params(self, **kwargs: Any) -> dict[str, Any]: + """Build query parameters, filtering None values.""" + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + def _build_payload(self, **kwargs: Any) -> dict[str, Any]: + """Build request payload, filtering None values.""" + from datetime import datetime + + def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + result: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + result[to_camel(k)] = v.isoformat() + else: + result[to_camel(k)] = v + return result + + def get_whats_app_phone_numbers( + self, *, status: str | None = None, profile_id: str | None = None + ) -> dict[str, Any]: + """List phone numbers""" + params = self._build_params( + status=status, + profile_id=profile_id, + ) + return self._client._get("/v1/whatsapp/phone-numbers", params=params) + + def purchase_whats_app_phone_number(self, profile_id: str) -> dict[str, Any]: + """Purchase phone number""" + payload = self._build_payload( + profile_id=profile_id, + ) + return self._client._post("/v1/whatsapp/phone-numbers/purchase", data=payload) + + def get_whats_app_phone_number(self, phone_number_id: str) -> dict[str, Any]: + """Get phone number""" + return self._client._get(f"/v1/whatsapp/phone-numbers/{phone_number_id}") + + def release_whats_app_phone_number(self, phone_number_id: str) -> dict[str, Any]: + """Release phone number""" + return self._client._delete(f"/v1/whatsapp/phone-numbers/{phone_number_id}") + + async def aget_whats_app_phone_numbers( + self, *, status: str | None = None, profile_id: str | None = None + ) -> dict[str, Any]: + """List phone numbers (async)""" + params = self._build_params( + status=status, + profile_id=profile_id, + ) + return await self._client._aget("/v1/whatsapp/phone-numbers", params=params) + + async def apurchase_whats_app_phone_number(self, profile_id: str) -> dict[str, Any]: + """Purchase phone number (async)""" + payload = self._build_payload( + profile_id=profile_id, + ) + return await self._client._apost( + "/v1/whatsapp/phone-numbers/purchase", data=payload + ) + + async def aget_whats_app_phone_number(self, phone_number_id: str) -> dict[str, Any]: + """Get phone number (async)""" + return await self._client._aget(f"/v1/whatsapp/phone-numbers/{phone_number_id}") + + async def arelease_whats_app_phone_number( + self, phone_number_id: str + ) -> dict[str, Any]: + """Release phone number (async)""" + return await self._client._adelete( + f"/v1/whatsapp/phone-numbers/{phone_number_id}" + ) diff --git a/test_upload.py b/test_upload.py deleted file mode 100644 index 3e718fa..0000000 --- a/test_upload.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Test script for upload module. -""" - -import sys -from pathlib import Path - -# Add src to path -sys.path.insert(0, str(Path(__file__).parent / "src")) - -# Test imports -print("Testing imports...") -from late import Late -from late.upload import ( - SmartUploader, - DirectUploader, - VercelBlobUploader, - UploadFile, - UploadResult, - UploadProgress, - LargeFileError, -) -print("āœ“ All imports successful") - -# Test files -SMALL_IMAGE = "/Users/carlos/Documents/WebDev/Freelance/miquel-palet/Schedule-Posts-API/app/apple-icon.png" -LARGE_VIDEO = "/Users/carlos/Documents/Video recordings/screen-studio/Built-in Retina Display.mp4" - -# Credentials -LATE_API_KEY = "sk_fb144cafa04c50eecb9102bb240657d4871f6fc5fd43eb9c22e4b869ff030c7e" -LATE_BASE_URL = "https://getlate.dev/api" -VERCEL_BLOB_TOKEN = "vercel_blob_rw_qf6opyLdArRJW0lJ_GBJHZ2I9KR0O1zo8iq31z96CrCAnUR" - -# Verify files exist -for path, name in [(SMALL_IMAGE, "Small image"), (LARGE_VIDEO, "Large video")]: - if Path(path).exists(): - size = Path(path).stat().st_size - print(f"āœ“ {name}: {size:,} bytes ({size / (1024*1024):.1f} MB)") - else: - print(f"āœ— {name} not found: {path}") - -# Create client -client = Late(api_key=LATE_API_KEY, base_url=LATE_BASE_URL) -print(f"\nāœ“ Late client created (base_url: {LATE_BASE_URL})") - -# Test 1: Direct upload (small file) -print("\n" + "="*60) -print("TEST 1: Direct upload (small file < 4MB)") -print("="*60) -try: - result = client.media.upload(SMALL_IMAGE) - print(f"āœ“ Upload successful!") - print(f" URL: {result['files'][0]['url']}") -except Exception as e: - print(f"āœ— Upload failed: {e}") - -# Test 2: Vercel Blob upload (large file) -print("\n" + "="*60) -print("TEST 2: Vercel Blob upload (large file ~278MB)") -print("="*60) - -def progress_callback(p: UploadProgress): - pct = p.percentage - bar = "ā–ˆ" * int(pct / 5) + "ā–‘" * (20 - int(pct / 5)) - print(f" [{bar}] {pct:.1f}%", end="\r") - -try: - result = client.media.upload_large( - LARGE_VIDEO, - vercel_token=VERCEL_BLOB_TOKEN, - on_progress=progress_callback - ) - print(f"\nāœ“ Vercel Blob upload successful!") - print(f" URL: {result['url']}") -except Exception as e: - print(f"\nāœ— Vercel Blob upload failed: {e}") - import traceback - traceback.print_exc() - -print("\n" + "="*60) -print("All tests completed!") -print("="*60) diff --git a/tests/conftest.py b/tests/conftest.py index 6810598..3120ad5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,4 +12,4 @@ def api_key() -> str: @pytest.fixture def base_url() -> str: """Test base URL.""" - return "https://getlate.dev/api" + return "https://zernio.com/api" diff --git a/tests/test_client.py b/tests/test_client.py index c5bcb66..37346fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,7 +12,7 @@ def test_client_init(self, api_key: str) -> None: """Test client initialization.""" client = Late(api_key=api_key) assert client.api_key == api_key - assert client.base_url == "https://getlate.dev/api" + assert client.base_url == "https://zernio.com/api" def test_client_custom_base_url(self, api_key: str) -> None: """Test client with custom base URL.""" diff --git a/tests/test_exhaustive.py b/tests/test_exhaustive.py index 9f6a243..d283c6e 100644 --- a/tests/test_exhaustive.py +++ b/tests/test_exhaustive.py @@ -22,7 +22,7 @@ def test_client_init_with_api_key(self): client = Late(api_key="test_key_123") assert client.api_key == "test_key_123" - assert client.base_url == "https://getlate.dev/api" + assert client.base_url == "https://zernio.com/api" def test_client_custom_base_url(self): """Test client with custom base URL.""" @@ -233,12 +233,12 @@ def test_import_platform_models(self): InstagramPlatformData, LinkedInPlatformData, PinterestPlatformData, - TikTokSettings, + TikTokPlatformData, TwitterPlatformData, YouTubePlatformData, ) - assert TikTokSettings is not None + assert TikTokPlatformData is not None assert TwitterPlatformData is not None assert InstagramPlatformData is not None assert FacebookPlatformData is not None @@ -258,17 +258,17 @@ class TestModelsValidation: """Test model validation.""" def test_status_enum_values(self): - """Test Status enum has expected values.""" + """Test Status enum has expected values. + + Note: The generated Status enum is for webhook log status (success/failed). + Post status values (draft/scheduled/published/failed) are in Status3. + """ from late.models import Status - # Enums use uppercase attribute names - assert hasattr(Status, "DRAFT") - assert hasattr(Status, "SCHEDULED") - assert hasattr(Status, "PUBLISHED") + assert hasattr(Status, "SUCCESS") assert hasattr(Status, "FAILED") - # Values are lowercase strings - assert Status.DRAFT.value == "draft" - assert Status.SCHEDULED.value == "scheduled" + assert Status.SUCCESS.value == "success" + assert Status.FAILED.value == "failed" def test_type_enum_values(self): """Test Type enum has expected values.""" @@ -280,17 +280,17 @@ def test_type_enum_values(self): assert Type.VIDEO.value == "video" def test_tiktok_settings_creation(self): - """Test TikTokSettings model creation.""" - from late.models import TikTokSettings - - settings = TikTokSettings( - allow_comment=True, - allow_duet=True, - allow_stitch=True, - privacy_level="PUBLIC", + """Test TikTokPlatformData model creation.""" + from late.models import TikTokPlatformData + + settings = TikTokPlatformData( + allowComment=True, + allowDuet=True, + allowStitch=True, + privacyLevel="PUBLIC", ) - assert settings.allow_comment is True - assert settings.privacy_level == "PUBLIC" + assert settings.allowComment is True + assert settings.privacyLevel == "PUBLIC" def test_media_item_creation(self): """Test MediaItem with type enum.""" diff --git a/uv.lock b/uv.lock index 754a336..2749bb1 100644 --- a/uv.lock +++ b/uv.lock @@ -478,6 +478,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -680,7 +723,7 @@ wheels = [ [[package]] name = "late-sdk" -version = "1.1.1" +version = "1.2.90" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -705,11 +748,14 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "respx" }, { name = "ruff" }, ] mcp = [ { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] @@ -727,8 +773,11 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "starlette", marker = "extra == 'mcp'", specifier = ">=0.42.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'mcp'", specifier = ">=0.32.0" }, ] provides-extras = ["ai", "anthropic", "all", "mcp", "dev"] @@ -1687,3 +1736,229 @@ sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef468 wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]