# Viraly API Reference for AI Agents

> This document is optimized for LLMs and AI agents to build integrations with the Viraly API. It contains every endpoint, parameter, and response shape in a flat, parseable format.

## API Overview

- **Base URL**: `https://api.viraly.io/api/v1`
- **Auth**: Bearer token in `Authorization` header
- **Token formats**: API keys start with `vly_`, OAuth tokens start with `vat_`
- **Content-Type**: `application/json` for request bodies
- **All responses** are wrapped in `{ "data": ..., "meta": { "requestId": "..." } }`
- **Paginated responses** include `"meta": { "pagination": { "page", "perPage", "total", "totalPages" } }`
- **Errors** return `{ "error": { "code": "...", "message": "...", "requestId": "..." } }`
- **IDs** are 8-character alphanumeric strings
- **Dates** are ISO 8601 format (e.g. `2026-04-15T14:00:00Z`)

## Authentication

```
Authorization: Bearer vly_your_api_key_here
```

API keys are workspace-scoped. Each key has granular permissions that control which endpoints it can access.

## Permissions

Each endpoint requires a specific permission. If the API key lacks the required permission, the API returns HTTP 403.

Available permissions: `posts:read`, `posts:write`, `channels:read`, `channels:write`, `analytics:read`, `biolinks:read`, `biolinks:write`, `media:read`, `media:write`, `social_sets:read`, `social_sets:write`, `categories:read`, `categories:write`, `hashtags:read`, `hashtags:write`, `ideas:read`, `ideas:write`, `feeds:read`, `feeds:write`, `url_shortener:read`, `url_shortener:write`, `subscribers:read`, `workspace:read`

## Rate Limits

120 requests per minute per workspace. Rate limits are shared across all API keys in the same workspace. Returns HTTP 429 when exceeded.

## Plan Availability

The public API is available on Agency ($99/mo) and Enterprise ($249/mo) plans.

## Pagination

List endpoints accept `page` (default 1) and `per_page` (default 25, max 100) query parameters.

---

## Posts

### GET /api/v1/posts

List posts with filtering and pagination. Permission: `posts:read`

Query parameters:
- `social_set_id` (string, optional) - Filter by social set
- `channel_id` (string, optional) - Filter by channel
- `status` (string, optional) - Filter: draft, scheduled, published, failed
- `start_date` (string, optional) - YYYY-MM-DD
- `end_date` (string, optional) - YYYY-MM-DD
- `page` (integer, optional) - Page number, default 1
- `per_page` (integer, optional) - Items per page, default 25, max 100
- `sort` (string, optional) - created_at, scheduled_at, or published_at
- `order` (string, optional) - asc or desc

Example request:
```
GET /api/v1/posts?status=published&per_page=2&sort=created_at&order=desc
Authorization: Bearer vly_your_api_key
```

Example response:
```json
{
  "data": [
    {
      "id": "NY7ObZ1D",
      "title": "Check out our latest update!",
      "status": "Published",
      "displayStatus": "Published",
      "scheduledAt": "2026-04-06T01:40:56Z",
      "createdAt": "2026-04-06T01:40:56Z",
      "channelId": "VQ4fKSE9",
      "timezone": "UTC",
      "queued": false,
      "categoryIds": [],
      "config": {
        "channelType": "Facebook",
        "facebook": {
          "contentOptions": {
            "postType": "Post",
            "message": "Check out our latest update!"
          }
        }
      },
      "metrics": {
        "updatedAt": "2026-04-08T12:20:16Z",
        "hasData": true,
        "facebook": {
          "reactionsTotal": 5,
          "impressions": 120,
          "reach": 85,
          "shares": 2,
          "comments": 1,
          "engagementRate": 6.7
        }
      },
      "postAttachments": [],
      "isEvergreen": false
    }
  ],
  "meta": {
    "requestId": "req_abc123",
    "pagination": {
      "page": 1,
      "perPage": 2,
      "total": 45,
      "totalPages": 23
    }
  }
}
```

### GET /api/v1/posts/{id}

Get a single post by ID. Permission: `posts:read`

Path parameters:
- `id` (string, required) - Post ID

Example request:
```
GET /api/v1/posts/NY7ObZ1D
Authorization: Bearer vly_your_api_key
```

### POST /api/v1/posts

Create a new post. Permission: `posts:write`

Example request:
```
POST /api/v1/posts
Authorization: Bearer vly_your_api_key
Content-Type: application/json

{
  "channelId": "VQ4fKSE9",
  "config": {
    "channelType": "Facebook",
    "facebook": {
      "contentOptions": {
        "postType": "Post",
        "message": "Exciting product launch coming soon!"
      }
    }
  },
  "scheduledAt": "2026-04-15T14:00:00Z",
  "timezone": "America/New_York"
}
```

Simplified request (caption-only): instead of a config object, send a top-level `caption` and Viraly builds the platform config for the channel. The optional `postType` field chooses the placement: `Feed` (default), `Reel` (Facebook, Instagram), `Story` (Facebook, Instagram), or `Short` (YouTube). Unsupported platform and placement combinations return a 400 error naming the platform. Pinterest also needs a `boardId`.

```
POST /api/v1/posts
Authorization: Bearer vly_your_api_key
Content-Type: application/json

{
  "channelId": "VQ4fKSE9",
  "caption": "Behind the scenes from today's shoot",
  "postType": "Story",
  "scheduledAt": "2026-04-15T14:00:00Z",
  "timezone": "America/New_York"
}
```

### PUT /api/v1/posts/{id}

Update an existing post. Permission: `posts:write`

Caption-only updates patch the existing post config: the placement, first comment, location, and other composer settings are preserved. Pass `postType` only to change the placement.

Path parameters:
- `id` (string, required) - Post ID

Request body: same shape as POST /api/v1/posts

### DELETE /api/v1/posts/{id}

Delete a post. Permission: `posts:write`

Path parameters:
- `id` (string, required) - Post ID

Returns empty response on success.

### PUT /api/v1/posts/{id}/schedule

Reschedule a post. Permission: `posts:write`

Path parameters:
- `id` (string, required) - Post ID

Example request:
```
PUT /api/v1/posts/NY7ObZ1D/schedule
Authorization: Bearer vly_your_api_key
Content-Type: application/json

{
  "scheduledAt": "2026-04-20T09:00:00Z",
  "timezone": "America/New_York"
}
```

### POST /api/v1/posts/{id}/publish

Publish a post immediately. Permission: `posts:write`

Path parameters:
- `id` (string, required) - Post ID

No request body required.

---

## Channels

### GET /api/v1/channels

List connected social media channels. Permission: `channels:read`

Query parameters:
- `social_set_id` (string, optional) - Filter by social set

Example response:
```json
{
  "data": [
    {
      "id": "VQ4fKSE9",
      "name": "My Brand Page",
      "type": "Facebook",
      "username": "",
      "accountType": "FacebookPage",
      "pictureUrl": "https://...",
      "status": "Active",
      "socialSetId": "TrrdKTIr",
      "createdAt": "2026-04-08T12:19:32Z",
      "order": 0,
      "scopes": ["pages_manage_posts", "read_insights"],
      "config": {}
    }
  ],
  "meta": { "requestId": "req_abc123" }
}
```

### GET /api/v1/channels/{id}

Get a single channel. Permission: `channels:read`

Path parameters:
- `id` (string, required) - Channel ID

### GET /api/v1/channels/{id}/stats

Get channel statistics. Permission: `channels:read`

Path parameters:
- `id` (string, required) - Channel ID

Example response:
```json
{
  "data": {
    "postsCount": 42
  },
  "meta": { "requestId": "req_abc123" }
}
```

---

## Social Sets

Social sets are logical groupings of channels (e.g., "Marketing Team", "Client A").

### GET /api/v1/social-sets

List all social sets. Permission: `social_sets:read`

Example response:
```json
{
  "data": [
    {
      "id": "TrrdKTIr",
      "name": "My Social Set",
      "code": "MS",
      "colorHex": "#9333EA",
      "timezone": "America/Chicago",
      "createdAt": "2026-04-04T19:26:41Z",
      "channels": [...]
    }
  ],
  "meta": { "requestId": "req_abc123" }
}
```

### GET /api/v1/social-sets/{id}

Get a social set. Permission: `social_sets:read`

### POST /api/v1/social-sets

Create a social set. Permission: `social_sets:write`

```json
{
  "name": "Client B",
  "timezone": "Europe/London"
}
```

### PUT /api/v1/social-sets/{id}

Update a social set. Permission: `social_sets:write`

---

## Bio Links

### GET /api/v1/biolinks

List bio link pages. Permission: `biolinks:read`

Query parameters:
- `social_set_id` (string, optional)

### GET /api/v1/biolinks/{id}

Get a bio link. Permission: `biolinks:read`

### POST /api/v1/biolinks

Create a bio link. Permission: `biolinks:write`

### PUT /api/v1/biolinks/{id}

Update a bio link. Permission: `biolinks:write`

### DELETE /api/v1/biolinks/{id}

Delete a bio link. Permission: `biolinks:write`

### GET /api/v1/biolinks/{id}/linked-channels

Get linked channels for a bio link. Permission: `biolinks:read`

### PUT /api/v1/biolinks/{id}/linked-channels

Update linked channels. Permission: `biolinks:write`

### GET /api/v1/biolinks/{id}/subscribers

List bio link subscribers (paginated). Permission: `subscribers:read`

Query parameters:
- `page` (integer, optional)
- `per_page` (integer, optional)

Example response:
```json
{
  "data": [
    {
      "id": "e4Yq9sL0",
      "email": "subscriber@example.com",
      "name": "Jane Doe",
      "createdAt": "2026-03-15T14:30:00Z"
    }
  ],
  "meta": {
    "requestId": "req_abc123",
    "pagination": { "page": 1, "perPage": 25, "total": 89, "totalPages": 4 }
  }
}
```

---

## Analytics

### POST /api/v1/analytics/{channelId}/sync

Trigger an analytics sync for a channel. Permission: `analytics:read`

Path parameters:
- `channelId` (string, required)

Example response:
```json
{
  "data": { "status": "sync_started", "channelId": "VQ4fKSE9" },
  "meta": { "requestId": "req_abc123" }
}
```

---

## Competitor Benchmarking

Track competitor social profiles and compare their performance against your own channels. Available on Business plans and above.

**Supported platforms:** Instagram (via Facebook Business Discovery API), YouTube (Data API v3), Facebook (Graph API).

**Features:**
- Add up to 5/15/30 competitor profiles (Business/Agency/Enterprise)
- Follower growth tracking with daily snapshots
- Engagement rate comparison and leaderboard
- Content type performance breakdown (Reels vs Posts vs Carousels)
- Posting frequency heatmap (day x hour)
- Best posting times by engagement
- Top hashtag analysis
- AI-powered content pillar classification
- Profile change detection (bio, website, picture changes)
- Side-by-side comparison dashboard with overlay charts

**How it works:**
- Instagram competitors require a connected Facebook Page (Meta's Business Discovery API requires Facebook Login tokens)
- YouTube competitors use a system API key (no per-user token needed)
- Facebook competitors use the tenant's Facebook Page token
- Data syncs hourly via AWS Lambda with jitter
- Historical data retention follows plan limits (Business: 12 months, Agency: 2 years, Enterprise: 5 years)

**Note:** Competitor tracking is not yet available via the Public API. Internal endpoints exist under `/api/competitors` for the Viraly SPA.

---

## Media

A media item is a file Viraly stores for use in posts. Every item has one of four statuses: `Uploading` (the file has not fully arrived), `Processing` (Viraly is measuring it), `Completed` (usable in a post) and `Failed` (final, with the reason in `error`). A post accepts a media item only once its status is `Completed`. Viraly sends no media webhook and no push: learn the outcome by reading the item (`GET /api/v1/media/{id}`, with `wait=true` to hold the request until it finishes).

Media item shape (returned by POST and GET /api/v1/media/{id}):
- `id` (string), `status` (one of the four above), `type` (`Photo`, `Video` or `Document`)
- `progress.stage`: `uploading`, `processing`, `ready` or `failed`
- `info`: `fileName`, `fileSize`, `mimeType`, `width`, `height`, `duration`, `url` (present once Completed)
- `thumbnails.small|medium|large`: `url`, `width`, `height` (present once Completed)
- `error`: `code` and `message` when Failed. Branch on `code`, never on the message. Codes: `unsupported_type`, `file_too_large`, `image_too_large`, `corrupt_media`, `conversion_failed`, `source_unreachable`, `source_forbidden`, `source_missing`, `processing_timeout`, `processing_crash`, `upload_abandoned`
- `upload`: present only on a create from local bytes while the bytes have not arrived: `method` (`PUT`), `url`, `headers`, `expiresAt`

### Idempotency-Key header (media creates and post writes)

Send `Idempotency-Key` on `POST /api/v1/media`, `POST /api/v1/posts` and `PUT /api/v1/posts/{id}`: one value per attempt series, 1 to 255 visible ASCII characters (a UUID works). Repeat the same value when you retry the same request, for example after a timeout, and use a new value for a new request. A repeat with the same key answers with the media item the first call created and never makes a second one.

- A create from local bytes REQUIRES the header. Without it the API answers 400 with code `idempotency_key_required`.
- A create from a url may omit it: the key is then derived from the url, so repeating the same url creates nothing twice.
- A value that is not 1 to 255 visible ASCII characters is refused with 400.

### POST /api/v1/media

Create a media item. Permission: `media:write`

Query parameters:
- `wait` (boolean, optional): hold the request until url files are `Completed` or `Failed`, up to the server's window

Request headers:
- `Idempotency-Key` (string): required for local bytes, optional for a url (see above)

Body fields:
- `url` (string): Viraly fetches the file itself. One request.
- or `fileName` (string), `fileSize` (integer), `mimeType` (string): local bytes. Two requests: create, then PUT the file to `upload.url` with EVERY header in `upload.headers` (they are part of the signature; S3 refuses the PUT without them).
- `files` (array of the above): several items in one call. Each file is matched to its item by position on a repeat.
- `socialSetId` (string, optional), `mediaCollectionId` (string, optional), `altText` (string, optional)

Example request (url):
```json
{
  "url": "https://example.com/launch.jpg",
  "socialSetId": "TrrdKTIr"
}
```

Example response (201):
```json
{
  "data": {
    "id": "f6Zr1tM2",
    "status": "Completed",
    "type": "Photo",
    "progress": { "stage": "ready" },
    "info": {
      "fileName": "launch.jpg",
      "fileSize": 182340,
      "mimeType": "image/jpeg",
      "width": 1200,
      "height": 800,
      "url": "https://media.viraly.io/media/abc123/Attachments/launch.jpg"
    }
  },
  "meta": { "requestId": "req_abc123" }
}
```

Example request (local bytes):
```
POST /api/v1/media
Idempotency-Key: 6f1d2c3e-9a4b-4c5d-8e7f-0a1b2c3d4e5f

{ "fileName": "clip.mp4", "fileSize": 48213000, "mimeType": "video/mp4", "socialSetId": "TrrdKTIr" }
```

Example response (201), then PUT the bytes to `upload.url` with `upload.headers`:
```json
{
  "data": {
    "id": "k2Pq8vLm",
    "status": "Uploading",
    "type": "Video",
    "progress": { "stage": "uploading" },
    "upload": {
      "method": "PUT",
      "url": "https://s3.amazonaws.com/...signed...",
      "headers": { "Content-Type": "video/mp4", "If-None-Match": "*" },
      "expiresAt": "2026-04-01T09:15:00Z"
    }
  },
  "meta": { "requestId": "req_abc123" }
}
```

Retrying local bytes: if the PUT fails or times out, repeat the POST with the same `Idempotency-Key`, `fileName`, `fileSize` and `mimeType`. The same item comes back with a fresh `upload` block while its bytes have not arrived; once they have, it comes back without one, and you read the item for its status. An item whose bytes never arrive ends `Failed` with `upload_abandoned`.

### GET /api/v1/media/{id}

Get a media item. Permission: `media:read`

Query parameters:
- `wait` (boolean, optional): hold the request until the item is `Completed` or `Failed`, or the server's fixed window ends. A client that still sees `Processing` calls again.

Example response:
```json
{
  "data": {
    "id": "f6Zr1tM2",
    "role": "PostAttachment",
    "type": "Photo",
    "status": "Completed",
    "progress": { "stage": "ready" },
    "tags": [],
    "socialSetId": "TrrdKTIr",
    "createdAt": "2026-04-01T08:15:00Z"
  },
  "meta": { "requestId": "req_abc123" }
}
```

### GET /api/v1/media

List media items (paginated). Permission: `media:read`

Query parameters:
- `social_set_id` (string, optional)
- `collection_id` (string, optional)
- `page` (integer, optional)
- `per_page` (integer, optional)

Example response:
```json
{
  "data": [
    {
      "id": "f6Zr1tM2",
      "role": "PostAttachment",
      "type": "Photo",
      "status": "Completed",
      "tags": [],
      "description": null,
      "socialSetId": "TrrdKTIr",
      "createdAt": "2026-04-01T08:15:00Z"
    }
  ],
  "meta": { "requestId": "req_abc123", "pagination": { ... } }
}
```

### DELETE /api/v1/media/{id}

Delete a finished media item (status `Completed` or `Failed`) and, shortly after, its stored files. Permission: `media:write`

An item that is still `Uploading` or `Processing` is refused with 409 and code `attachment_unfinished`: read it until it has finished, then delete it. An item used by a post cannot be deleted while the post holds it.

### GET /api/v1/media/collections

List media collections. Permission: `media:read`

Query parameters:
- `social_set_id` (string, optional)

---

## Hashtags

### GET /api/v1/hashtags

List hashtag groups. Permission: `hashtags:read`

Query parameters:
- `social_set_id` (string, optional)

Example response:
```json
{
  "data": [
    {
      "id": "g8As3uN4",
      "topic": "Product Launch",
      "hashtags": "#launch #newproduct #excited"
    }
  ],
  "meta": { "requestId": "req_abc123" }
}
```

### POST /api/v1/hashtags

Create a hashtag group. Permission: `hashtags:write`

### PUT /api/v1/hashtags/{id}

Update a hashtag group. Permission: `hashtags:write`

### DELETE /api/v1/hashtags/{id}

Delete a hashtag group. Permission: `hashtags:write`

---

## Categories

### GET /api/v1/categories

List post categories. Permission: `categories:read`

Example response:
```json
{
  "data": [
    {
      "id": "jde4tXMA",
      "name": "Educational",
      "color": "#3B82F6",
      "sortOrder": 1,
      "postCount": 12,
      "createdAt": "2026-04-04T19:26:40Z"
    }
  ],
  "meta": { "requestId": "req_abc123" }
}
```

### POST /api/v1/categories

Create a category. Permission: `categories:write`

### PUT /api/v1/categories/{id}

Update a category. Permission: `categories:write`

### DELETE /api/v1/categories/{id}

Delete a category. Permission: `categories:write`

---

## Ideas

### GET /api/v1/idea-boards

List idea boards. Permission: `ideas:read`

Query parameters:
- `social_set_id` (string, optional)

Example response:
```json
{
  "data": [
    {
      "id": "EMgPC4hk",
      "name": "To Do",
      "description": "Ideas ready to work on",
      "colorHex": "#3B82F6",
      "displayOrder": 1,
      "socialSetId": "TrrdKTIr",
      "ideas": [
        {
          "id": "YtzAdCPI",
          "title": "Behind-the-scenes video",
          "caption": "Show the team working on the new feature",
          "status": "Draft",
          "displayOrder": 0,
          "boardId": "EMgPC4hk"
        }
      ]
    }
  ],
  "meta": { "requestId": "req_abc123" }
}
```

### GET /api/v1/idea-boards/{boardId}/ideas

List ideas in a board. Permission: `ideas:read`

### POST /api/v1/ideas

Create an idea. Permission: `ideas:write`

```json
{
  "boardId": "EMgPC4hk",
  "title": "Behind-the-scenes video",
  "caption": "Show the team working on the new feature",
  "notes": "Film during standup on Thursday"
}
```

### PUT /api/v1/ideas/{id}

Update an idea. Permission: `ideas:write`

### DELETE /api/v1/ideas/{id}

Delete an idea. Permission: `ideas:write`

---

## Feeds

### GET /api/v1/feed-collections

List feed collections. Permission: `feeds:read`

Query parameters:
- `socialSetId` (string, required)

### GET /api/v1/feeds

List feeds. Permission: `feeds:read`

Query parameters:
- `socialSetId` (string, required)

### POST /api/v1/feeds

Create a feed. Permission: `feeds:write`

### DELETE /api/v1/feeds/{id}

Delete a feed. Permission: `feeds:write`

---

## URL Shortener

### GET /api/v1/short-urls

List shortened URLs (paginated). Permission: `url_shortener:read`

Query parameters:
- `page` (integer, optional)
- `per_page` (integer, optional)
- `search` (string, optional)

Example response:
```json
{
  "data": [
    {
      "id": 1234,
      "shortCode": "abc123",
      "originalUrl": "https://example.com/long-url",
      "shortUrl": "https://vly.to/abc123",
      "totalClicks": 847,
      "createdAt": "2026-03-01T10:00:00Z"
    }
  ],
  "meta": { "requestId": "req_abc123", "pagination": { ... } }
}
```

### POST /api/v1/short-urls

Create a shortened URL. Permission: `url_shortener:write`

```json
{
  "url": "https://example.com/my-long-campaign-url?utm_source=social"
}
```

URL must be valid HTTP or HTTPS.

### GET /api/v1/short-urls/{shortCode}/clicks

Get click analytics for a shortened URL. Permission: `url_shortener:read`

Path parameters:
- `shortCode` (string, required)

---

## Workspace

### GET /api/v1/workspace

Get current workspace info. Permission: `workspace:read`

Example response:
```json
{
  "data": {
    "id": "YNFoJ08S",
    "name": "Acme Marketing",
    "plan": "Business",
    "status": "Active",
    "createdAt": "2025-06-01T00:00:00Z"
  },
  "meta": { "requestId": "req_abc123" }
}
```

### GET /api/v1/workspace/users

List workspace users (paginated). Permission: `workspace:read`

Returns name and role only (no email or PII).

Query parameters:
- `page` (integer, optional)
- `per_page` (integer, optional)

Example response:
```json
{
  "data": [
    {
      "id": "FZLJhAp0",
      "name": "John Smith",
      "role": "Owner",
      "status": "Active",
      "createdAt": "2025-06-01T00:00:00Z"
    }
  ],
  "meta": {
    "requestId": "req_abc123",
    "pagination": { "page": 1, "perPage": 25, "total": 3, "totalPages": 1 }
  }
}
```

---

## Supported Social Platforms

Viraly supports scheduling and analytics for: Facebook, Instagram, TikTok, Twitter/X, LinkedIn, YouTube, Pinterest, Threads, Bluesky, Mastodon, WordPress, Shopify.

## Error Codes

| HTTP Status | Meaning |
|-------------|---------|
| 200 | Success |
| 400 | Bad request - invalid parameters |
| 401 | Unauthorized - invalid or missing API key |
| 403 | Forbidden - API key lacks required permission |
| 404 | Not found - resource doesn't exist or belongs to another workspace |
| 409 | Conflict - the resource is not in a state that allows the action (for example a media item still Uploading or Processing: `attachment_unfinished`) |
| 429 | Rate limit exceeded - check Retry-After header |
