Skip to main content

Publish on a channel

Read your organization's channels and their categories, publish an asset on a channel with POST /v1/publications — status, scheduling, access, address, filing —, change it with PATCH, and run a sync again without changing anything.

A channel is a public site where your organization publishes videos for its audience. The API reads three things about channels and writes one:

Resource What it is Read Write
GET /v1/channels the organization’s channels websites.catalogue.read —
GET /v1/categories each channel’s tree of categories websites.catalogue.read from Videas
GET /v1/publications what each channel publishes websites.catalogue.read websites.catalogue.publish

On the keys screen, the two permissions are Read the channels through the API and Publish on the channels through the API. Neither implies the other, nor any permission of the library: a key that publishes does not edit an asset, and does not read the library.

Before you start

  • Your organization must publish channels. For an organization that does not, every endpoint of this page answers 403 to all of its keys — as it would a key without these permissions. A key may carry them beforehand: it works with no change the day the organization has its channels.
  • An organization whose channels are still being moved to Videas reads them as usual, but every write on them is refused with a 409 until the move is complete: they are changed from the platform they are moving from until then.
  • The assets to publish are in the library: a file added and processed, or a video hosted elsewhere.

The channels

curl https://api.videas.com/v1/channels \
  -H "Authorization: Bearer $VIDEAS_API_KEY"
{
  "data": [
    {
      "id": "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b",
      "name": "Materials TV",
      "url": "https://webtv.example.com",
      "language": "en",
      "created_at": "2026-02-10T09:00:00Z",
      "updated_at": "2026-09-30T16:12:45Z"
    }
  ],
  "next": null
}
Field
id The channel’s identifier — what the channel_id of a category, and of a publication, names.
name Its name, as its audience reads it.
url The address of its home page: its main domain, followed by a language segment such as /fr when that domain serves several channels, one per language. Every address on the channel starts with it. null while no domain of the channel is switched on.
language The language it is written in, as a BCP 47 tag.
created_at, updated_at Its creation and its last change. A change of its domains alone, which moves its url, does not move updated_at.

The list carries every channel of the organization, online or not: url says where the channel answers once it is online, not whether it is. GET /v1/channels/count counts them, and GET /v1/channels/{channel_id} reads one.

The categories

Each channel files its videos in a tree of categories, arranged from Videas and read here. GET /v1/categories?channel_id= lists one channel’s — without channel_id, every channel’s:

curl "https://api.videas.com/v1/categories?channel_id=0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b" \
  -H "Authorization: Bearer $VIDEAS_API_KEY"
{
  "data": [
    {
      "id": "0192a6a1-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "channel_id": "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b",
      "parent_id": null,
      "name": "Processes",
      "slug": "processes",
      "position": 0,
      "is_active": true,
      "url": "https://webtv.example.com/categories/processes/",
      "created_at": "2026-02-10T09:12:00Z",
      "updated_at": "2026-02-10T09:12:00Z"
    },
    {
      "id": "0192a6a1-2b3c-7d4e-9f5a-6b7c8d9e0f1a",
      "channel_id": "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b",
      "parent_id": "0192a6a1-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "name": "Recycling",
      "slug": "recycling",
      "position": 1,
      "is_active": true,
      "url": "https://webtv.example.com/categories/recycling/",
      "created_at": "2026-02-10T09:12:40Z",
      "updated_at": "2026-02-10T09:12:40Z"
    },
    {
      "id": "0192a6a1-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
      "channel_id": "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b",
      "parent_id": null,
      "name": "Events",
      "slug": "events",
      "position": 1,
      "is_active": true,
      "url": "https://webtv.example.com/categories/events/",
      "created_at": "2026-02-10T09:13:05Z",
      "updated_at": "2026-02-10T09:13:05Z"
    }
  ],
  "next": null
}
Field
parent_id The category it sits under, of the same channel; null at the root.
position Its place among the categories sharing its parent, lowest first. Not unique: at one position, the order is the names’.
slug The handle its address is built on, unique within its channel.
is_active Whether the audience sees it. A category switched off is hidden with everything below it; it keeps what is filed in it, and shows it again once switched back on.
url The address of its page: the channel’s url followed by /categories/{slug}/; null when the channel’s is. It answers 404 while the category, or one above it, is switched off.
updated_at Its last change, moving it under another parent included.

Rebuild the tree

The list is flat, in the order the categories were created — not the order the channel shows them in. Rebuild the tree from parent_id, and sort the categories sharing a parent by position, then by name:

from collections import defaultdict

def category_tree(categories):
    """A channel's categories as a tree: [(category, [subtree…]), …]."""
    children = defaultdict(list)
    for category in categories:
        children[category["parent_id"]].append(category)

    def branch(parent_id):
        siblings = sorted(children[parent_id], key=lambda c: (c["position"], c["name"]))
        return [(category, branch(category["id"])) for category in siblings]

    return branch(None)

A category switched off is in the list, and a publication may be filed in it — which is how a team prepares a section before opening it. channel_id takes one identifier; GET /v1/categories/count counts under the same filter.

The publications

A publication is one asset published on one channel, at its own address, and filed in that channel’s categories. An asset is published at most once on a channel.

{
  "id": "0192a6e5-4c5d-7e6f-9a0b-1c2d3e4f5a6b",
  "channel_id": "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b",
  "asset_id": "0192a6e4-3b5c-7d6e-8f90-a1b2c3d4e5f6",
  "slug": "recycling-carbon-fibre-three-processes-compared",
  "url": "https://webtv.example.com/recycling-carbon-fibre-three-processes-compared/",
  "status": "published",
  "published_at": "2026-10-15T08:30:21Z",
  "is_listed": true,
  "access": "public",
  "category_ids": [
    "0192a6a1-2b3c-7d4e-9f5a-6b7c8d9e0f1a",
    "0192a6a1-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
  ],
  "primary_category_id": "0192a6a1-2b3c-7d4e-9f5a-6b7c8d9e0f1a",
  "created_at": "2026-10-15T08:30:21Z",
  "updated_at": "2026-10-15T08:30:21Z"
}
Field
channel_id, asset_id The channel and the asset. They never change.
slug, url Its address: the channel’s url followed by /{slug}/; null when the channel’s is.
status, published_at Where it is, and when it goes on the channel — see Status and scheduling.
access Who it is for — see Who it is for.
is_listed Whether the channel shows it in its catalog, its categories, its search and its sitemap. A publication that is not listed still answers at its url.
category_ids The categories it is filed in — a set: its main category comes first when it has one, the others following in the order GET /v1/categories lists them. Empty when it is filed nowhere.
primary_category_id Its main category, the one its page and its card name — one of category_ids; null when it has none.
updated_at The last change of its own settings — its address, its status, its date, whether it is listed, who it is for. Filing it in other categories does not move it.

List and find

GET /v1/publications lists every channel’s publications, whatever their status, page by page, and GET /v1/publications/count counts them under the same filters:

Parameter Keeps Example
channel_id the publications of this channel ?channel_id=0192a6a0-…
asset_id the publications of this asset — where it is published ?asset_id=0192a6e4-…
status these statuses, separated by commas ?status=draft,published

?asset_id= says where an asset is published; with channel_id beside it, whether it is published on that channel — the answer then carries one publication or none. Only publications whose asset is in GET /v1/assets are listed.

Status and scheduling

status Meaning
draft Not on the channel.
published On the channel from its published_at — which may be still to come: a scheduled publication is published with a future date, and the channel shows it from that instant.
archived Taken off the channel, and kept with its date.

published_at is null for a draft never published; a draft or an archived publication keeps the date it last went out with. Its url answers 404 until it is published and its published_at has come. The list of statuses may grow: read a value you do not know as a publication the channel does not show.

Who it is for

access says who the publication is for. It is read in five words:

access For whom
public anybody the channel lets in
authenticated anybody who signed in to the channel, or gave its password
restricted only the members granted it
paid the members who bought it, or hold a subscription covering it
on_request the members who asked for it and were granted it

And it is written in two: public and authenticated. The other three stand for a right or an offer set from Videas: a request may neither set one (400) nor move a publication off one (409), since leaving it would undo the offer or the grant. The list may grow: read a value you do not know as restricted.

  • It is the publication’s own setting, not what a visitor gets: on a channel that asks every visitor to sign in, a public publication is still behind that sign-in.
  • Left out of a creation, the channel decides: on a channel whose visitors open an account themselves and whose site is otherwise open, a new publication asks for that account (authenticated); on any other, it is open to everyone the channel lets in (public). Send access if you want to be sure of the result.
  • Created authenticated — sent so, or given so by the channel —, a publication still shows itself, locked, to the visitors it is not for — in the channel’s listings when it is listed, and at its page —, and playing it asks them to sign in. Moved to authenticated later by a PATCH, a publication created public does not show itself so: those visitors do not see it at all. A PATCH moves the level alone, and nothing else.

Its address

slug is the handle the publication’s url is built on: lowercase letters and digits in words joined by single hyphens, at most 255 characters, unique among the channel’s publications.

  • Left out of a creation, it is made from the asset’s own handle where it has one, else from its name, followed by -2, -3 and on where the address is taken.
  • Sent, it is taken as it is: 400 when the channel’s own site answers that address itself, 409 when another publication or a page of the channel holds it — the message names the address.
  • A new slug leaves the old address answering, sending visitors to the new one.

Its filing

category_ids lists the channel’s categories the publication is filed in, and primary_category_id its main category, which must be among them.

  • At most 100 categories, all of the publication’s channel — switched-off ones included; an id sent twice counts once.
  • It is a set. Read, its order says one thing only: the main category comes first. Sent, the order is ignored.
  • Sent, the list replaces the set. A category the publication stays in keeps the place and the featuring the team gave it there in Videas; a category it enters puts it last; [] files it nowhere.
  • The order inside a category and the featuring are set in Videas, not through the API.

Publish an asset

curl -X POST https://api.videas.com/v1/publications \
  -H "Authorization: Bearer $VIDEAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel_id": "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b",
    "asset_id": "0192a6e4-3b5c-7d6e-8f90-a1b2c3d4e5f6",
    "status": "published",
    "access": "public",
    "category_ids": [
      "0192a6a1-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "0192a6a1-2b3c-7d4e-9f5a-6b7c8d9e0f1a"
    ],
    "primary_category_id": "0192a6a1-2b3c-7d4e-9f5a-6b7c8d9e0f1a"
  }'
Field
channel_id Required. The channel.
asset_id Required. The asset. It must be ready, and a channel publishes a video, a sound, a picture or a video hosted elsewhere — not a document.
status draft, the default, or published. A publication is never created archived.
published_at With status: published, when it goes on the channel (RFC 3339) — a future date schedules it. Absent: now. Refused on a draft.
access public or authenticated. Absent: the channel’s choice.
slug Its address. Absent: made from the asset.
is_listed Whether the channel shows it in its listings — true unless you send false.
category_ids, primary_category_id Its filing — each category takes it last. Absent: filed nowhere, with no main category.

The answer is a 201, with Location: /v1/publications/{publication_id} and the whole publication, as above.

  • An asset not ready yet — a file still being processed — is a 409: send the request again once the asset is ready. A video hosted elsewhere is ready from the moment it exists.
  • An asset already published on this channel is a 409 too: GET /v1/publications?channel_id=…&asset_id=… answers its publication.

Change a publication

PATCH /v1/publications/{publication_id} writes what you send, and leaves the rest as it is. Its channel and its asset never change: sending them is a 422.

# Schedule it for 2 November, 9 a.m. Paris time
curl -X PATCH https://api.videas.com/v1/publications/0192a6e5-4c5d-7e6f-9a0b-1c2d3e4f5a6b \
  -H "Authorization: Bearer $VIDEAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "published", "published_at": "2026-11-02T08:00:00Z"}'
  • status: published puts it on the channel — at the date sent, else now: one taken off and put back on comes back as new, not under the date it first went out on. draft or archived takes it off, keeping its date.
  • published_at sent alone moves the date of a publication already published; on any other, or beside status: draft or archived, it is a 400.
  • slug renames it, and the old address sends visitors to the new one.
  • category_ids replaces its filing; primary_category_id changes its main category, or removes it with null. Left out, the main category stays while the publication is still filed there, and there is none otherwise.
  • access moves it between public and authenticated.
  • is_listed shows it in the channel’s listings, or takes it out of them.
  • Only primary_category_id takes null: on any other field, null is a 400.

The answer is the whole publication. A PATCH that changes nothing writes nothing, updated_at included, and the last request to arrive wins.

Retire a publication

Nothing deletes a publication. To take it off the channel, archive it — {"status": "archived"} —: it is kept with everything attached to it, and is published again with a PATCH. To free its address, give it another slug.

Retrying safely

  • A PATCH is sent again as it is after a timeout or a 5xx: sent twice, the second finds the publication in the state asked for already.
  • POST /v1/publications publishes an asset once on a channel: sent again, it is refused with a 409. After a timeout or a 5xx, look for the publication the first request may have made with GET /v1/publications?channel_id=…&asset_id=… before sending it again.

End to end

This script syncs one video from a spreadsheet to a channel: it creates the YouTube video in the library, tags it, fills a “Useful link” field, and publishes it in two categories. Run a second time, it changes nothing: each step looks first for what exists, and each write states the state wanted rather than a change.

It needs a key carrying six permissions: reading the library, adding files to it, importing videos from other platforms, editing its assets, reading the channels and publishing on them.

import os

import requests

API = "https://api.videas.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['VIDEAS_API_KEY']}"

WORKSPACE_ID = "0192a6b1-7c3e-7d40-9a51-3f0e2b6c8d10"
CHANNEL_ID = "0192a6a0-5d6e-7f80-9a1b-2c3d4e5f6a7b"
USEFUL_LINK_FIELD_ID = "0192a680-1d2e-7f3a-8b4c-5d6e7f8a9b0c"

# One row of the spreadsheet.
VIDEO = {
    "external_ref": "webtv/2026/carbon-fibre-recycling",
    "youtube_url": "https://www.youtube.com/watch?v=Xk3Ua9yQe7M",
    "tags": ["Carbon fibre", "Recycling"],
    "useful_link": {
        "url": "https://example.com/carbon-fibre-recycling",
        "label": "The full report",
    },
    "categories": ["processes", "recycling"],  # the slugs of the channel's categories
    "main_category": "recycling",
}


def call(method, path, **kwargs):
    response = session.request(method, f"{API}{path}", **kwargs)
    if not response.ok:
        error = response.json()
        raise RuntimeError(f"{method} {path} → {response.status_code} {error['code']}: {error['message']}")
    return response.json()


def all_pages(path, **params):
    while True:
        page = call("GET", path, params=params)
        yield from page["data"]
        if page["next"] is None:
            return
        params["after"] = page["next"]


def find_or_create_video(video):
    """This row's asset: found by its reference, else created."""
    found = call("GET", "/assets", params={"external_ref": video["external_ref"]})["data"]
    if found:
        return found[0]
    return call("POST", "/assets", json={
        "workspace_id": WORKSPACE_ID,
        "third_party_url": video["youtube_url"],
        "external_ref": video["external_ref"],
    })


def publish(asset_id, category_ids, main_category_id):
    """The asset's publication on the channel, in the state wanted."""
    wanted = {
        "status": "published",
        "access": "public",
        "category_ids": category_ids,
        "primary_category_id": main_category_id,
    }
    found = call("GET", "/publications", params={"channel_id": CHANNEL_ID, "asset_id": asset_id})["data"]
    if found:
        return call("PATCH", f"/publications/{found[0]['id']}", json=wanted)
    return call("POST", "/publications", json={"channel_id": CHANNEL_ID, "asset_id": asset_id, **wanted})


asset = find_or_create_video(VIDEO)
call("PUT", f"/assets/{asset['id']}/tags", json={"tags": VIDEO["tags"]})
call("PATCH", f"/assets/{asset['id']}/custom-fields", json={
    "values": [{"field_id": USEFUL_LINK_FIELD_ID, "value": VIDEO["useful_link"]}],
})

categories = {c["slug"]: c["id"] for c in all_pages("/categories", channel_id=CHANNEL_ID)}
publication = publish(
    asset["id"],
    [categories[slug] for slug in VIDEO["categories"]],
    categories[VIDEO["main_category"]],
)
print(publication["status"], publication["url"])

The first run creates the asset (201), sets its two tags, fills its link, and publishes the video (201):

published https://webtv.example.com/recycling-carbon-fibre-three-processes-compared/

The second finds the asset by its external_ref and the publication by ?channel_id=…&asset_id=…: it sends neither POST /v1/assets nor POST /v1/publications. Its three writes — the tags, the link, the publication — send what the asset and the publication already hold, and write nothing: the same answers, and no updated_at that moves. It prints the same line.

Two things to know before making it your sync:

  • The script states the state wanted, and restores it: a category added by hand in Videas, or a publication archived since, is undone on the next run. Have it send only the fields your data source is the reference for.
  • A row’s POST /v1/assets is never sent again once it is created, because the lookup by external_ref comes first. Without it, a retry would create a second asset — Retrying safely.

Errors

Every error has the same body — {"code": "…", "message": "…", "request_id": "…"}. Branch on code; message is written for a human and may change.

Status code What happened
400 validation Creation or change: status: archived on a creation, or a published_at beside a draft; a published_at alone on a publication that is not published; a slug that is malformed, too long, or used by the channel’s own site; an asset of a kind a channel does not publish; more than 100 category_ids, a category of another channel, or a main category that is not among them; an access other than public or authenticated; null on a field that cannot be emptied. Reads: a channel_id or an asset_id that is not one uuid, an unknown status, an unknown parameter. The message names the field.
401 unauthorized No key, or a key that is unknown, expired or revoked.
403 forbidden The key does not carry websites.catalogue.read (reads) or websites.catalogue.publish (writes) — or the organization does not publish channels.
404 not_found No channel of GET /v1/channels, no asset of GET /v1/assets or no publication of GET /v1/publications has this id — in the path as in the body.
409 conflict The asset is already published on this channel; it is not ready yet; the slug is taken by another publication or a page of the channel; access would move a publication that is restricted, paid or on_request; or the organization’s channels are still being moved to Videas.
415 unsupported_media_type The body is not sent as application/json.
422 validation channel_id or asset_id missing on a creation, or sent on a change; a field of the wrong type; a status or an access that names nothing; a field the body does not list.
429 rate_limited Too many requests: wait the number of seconds Retry-After gives.

Next steps