---
url: /publishing.md
---

# Publishing a video

The [quickstart](/quickstart) ran the whole loop in one pass. This page is the
model underneath it: assets, publications, targets, and the states they travel.

## Connections come first

You can only publish to a destination you've connected. A connection is a
platform account you've authorized socialized.dev to publish to: a YouTube
channel, an Instagram account, a Facebook page. A brand holds at most one
connection per platform.

Connecting uses OAuth, so it happens in a browser, which is a dashboard step.
Connection management, including listing active connections or removing them, is done
exclusively in the dashboard and is not available via API keys.

## Assets: upload once

The source video is an **asset**, stored once. Assets are **account-scoped**, meaning a video uploaded once can be published under any brand in the account.

> \[!NOTE]
> If you are using the **socialized.dev CLI** (`socialized.dev publications create`), the CLI automatically handles the multipart asset upload ceremony internally for local file paths, making it a single step.

For direct API integrations, uploads are presigned and multipart: you upload each part directly to a URL the API returns:

1. `POST /assets` with `filename`, `contentType`, and `sizeBytes`: returns a plan with one presigned `PUT` URL per part (parts are 10 MiB).
2. `PUT` each part directly to its URL; keep each `ETag`. Part URLs are signed for `host` only, so no extra headers are required — a plain `PUT` with a normal `Content-Length` (no chunked transfer encoding) is enough. A `Content-Type` on a part is ignored: the asset's type is the `contentType` from step 1.
3. `POST /assets/{uploadId}/complete` with the `{ partNumber, etag }[]` (ETags may be quoted or unquoted). The asset is recorded and becomes `ready`.

A cover is uploaded as its own image asset (`POST /assets` with an image `contentType`) and
referenced by the publication via `coverAssetId`. Keep it under **2 MB** — YouTube's thumbnail
API refuses anything larger, and a YouTube target with an over-2MB cover fails validation at
publish time rather than posting without its thumbnail. A JPEG at 1080p is typically well
under the cap.

An asset only ever reaches `ready`: there is no half-uploaded state to publish
by accident.

**Upload, then publish within 48 hours.** An asset that isn't attached to a
publication — as the video or the cover — within **48 hours** of upload is
reclaimed automatically: both the stored file and its record are deleted. The
clock runs from when you upload, not from last use, so finish the publish inside
that window. Once an asset is attached to a publication it persists.

## Publications & targets

A **publication** is one "post once": a title, a description, an optional cover,
and a list of **targets**. Each target is one destination.

```sh
curl -s -X POST "$API/brands/$BRAND/publications" \
  -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "videoAssetId": "asset_video_…",
    "coverAssetId": "asset_cover_…",
    "title": "Launch clip",
    "description": "…",
    "targets": [
      { "platform": "youtube" },
      { "platform": "instagram" }
    ]
  }'
```

Each target carries a per-platform `options` object (it defaults to `{}`). Up to
10 targets per call. An override wins over the publication's base
`title` / `description`; an absent field falls back to the base.

**YouTube** `options`:

| Field | Type | Limit / values | Default | Fallback when absent |
| --- | --- | --- | --- | --- |
| `title` | string | 1–100 chars | — | base `title` |
| `description` | string | max 5000 chars | — | base `description` |
| `tags` | string\[] | 500 chars total (the joining commas count) | — | none sent |
| `categoryId` | string | — | — | none sent |
| `privacyStatus` | enum | `public` | `unlisted` | `private` | `public` | — |
| `madeForKids` | boolean | — | `false` | — |

**Instagram** `options`:

| Field | Type | Limit | Fallback when absent |
| --- | --- | --- | --- |
| `caption` | string | max 2200 chars | base `description`, then base `title` if it is empty |

**Facebook** `options`:

| Field | Type | Limit | Fallback when absent |
| --- | --- | --- | --- |
| `title` | string | 1–250 chars | base `title` |
| `description` | string | max 4000 chars | base `description`, then base `title` if it is empty |
| `format` | `'auto'` | `'reel'` | `'video'` | — | `'auto'` |

**Reels vs Page videos.** Facebook has two publish surfaces. Reels go through
`/{page-id}/video_reels` and must sit in the 3–180s window (Meta raised the old
90s cap to ~3 min mid-2025). Normal Page videos go through `/{page-id}/videos`,
which accepts much longer clips (~4 GB / ~240 min by third-party consensus; the
authoritative per-Page answer is `GET /{page-id}?fields=video_upload_limits`).
With `format: 'auto'` (the default), a clip that fits the Reels window is
published as a Reel; everything else — including an unknown duration — lands as
a Page video, so a long clip is never rejected when a safe endpoint exists.
Explicit `reel` fails validation outside that window. v1's `/videos` path is
hosted-URL only (`file_url` body param); there is no byte-push fallback.

TikTok has no connector yet, so its `options` stay a free-form bag.

The API validates the **effective** message (the override, or the base it falls
back to) against each platform's limits. A field that overflows fails the request
with a `422 platform_message_too_long` error carrying `{ platform, field, limit }`
— this catches a base message too long for a platform even when no override is set
(for example a 150-character base title fanned out to YouTube's 100-character cap).

The API validates before it accepts: the video and cover assets must be `ready`. You do
not specify a connection ID; the server automatically resolves the brand's single active
connection for each platform. If a platform has no active connection, the request fails
with a `422 platform_not_connected` error. If you specify the same platform twice in one
call, the request fails with a `422 duplicate_platform` error.

## The lifecycle

Once accepted, the publication runs on its own: each target is sent, transient
failures are retried, and every target settles to a final state you can read
back. Each target travels:

| Target status | Meaning |
| --- | --- |
| `pending` | accepted, waiting to send |
| `processing` | being sent to the platform |
| `published` | live; `externalUrl` is the post |
| `failed` | the platform rejected it; `error` says why |
| `deleted` | the live post was deleted on its platform through the API (see below) |

The publication aggregates its targets:

| Publication status | Meaning |
| --- | --- |
| `queued` | accepted, nothing sent yet |
| `publishing` | at least one target is in flight |
| `done` | every target published |
| `partial` | some published, some failed |
| `failed` | every target failed |

Read it back with `GET /brands/{brandId}/publications/{id}` (the full record, with every target),
or list the brand's publications with `GET /brands/{brandId}/publications`. Or skip polling
entirely and subscribe to a [webhook](/webhooks).

## Add destinations, retry failures

A publication is not frozen once sent:

* **`PATCH /brands/{brandId}/publications/{id}`**: edit the publication details (such as `title`, `description`, `coverAssetId`, or `scheduledAt`) and optionally add new targets. Already-live posts are left untouched.
* **`POST /brands/{brandId}/publications/{id}/targets`**: an append-only endpoint to send the video to more destinations without editing the message. Already-live posts are left untouched.
* **`POST /brands/{brandId}/publications/{id}/targets/{targetId}/retry`**: re-queue a `failed` target. Only failed targets are retryable: any other status returns a `409` conflict.

Writes to a removed publication (see below) return `409 publication_removed` — a removed record is
readable but frozen.

## Deleting

Deletion is two separate operations — one for your records, one for the live posts — because platform
support is uneven:

* **`DELETE /brands/{brandId}/publications/{id}`** removes the publication from **socialized.dev
  only**: it leaves your list (reads by id keep working, with `archivedAt` set), and every post
  already live on a platform **stays live**. Returns `409 publication_in_flight` while targets are
  still publishing.
* **`DELETE /brands/{brandId}/publications/{id}/targets/{targetId}/post`** deletes the target's
  **live post on its platform**. Supported for **YouTube** and **Facebook**; Instagram's API cannot
  delete a published reel (`422 platform_delete_unsupported`). The target settles to `deleted`
  (external ids are kept for your records), a `publication.target.deleted`
  [webhook](/webhooks) fires, and the call is idempotent — deleting an already-`deleted` target, or a
  post that was already removed on the platform, succeeds.
