Vodmill docs
v1.0.0
Get help
● Publishing · Post and schedule

Publishing
YouTube, Postiz, or a signed webhook.

With publishing switched on, customers post or schedule a rendered clip from the app. Three kinds of channel exist: the customer's own YouTube channel through your Google OAuth client, the customer's own Postiz, and a webhook for automation tools.

Off by defaultYour Google OAuth clientPostizSigned webhook

01What it does, and what it does not

ChannelWhat it isWho holds the account
YouTubeDirect upload with the YouTube Data API, through the operator's Google OAuth clientThe customer's own channel
PostizThe customer's own Postiz (self-hosted or cloud): address and API key. The accounts connected there (TikTok, Instagram, X, LinkedIn, Facebook and others) become places to post.The customer's Postiz
WebhookOne signed request with a link to the file and the post text, for Zapier, Make, n8n or your own codeThe customer's receiver
No direct TikTok or Instagram posting

Vodmill never posts to TikTok or Instagram itself. Those networks are reached only through the customer's own Postiz or a webhook, and the app says so. For a Postiz target, "posted" means handed over: Postiz does the posting and shows the result. Nothing here promises reach or views.

02Console settings

Console, Product, Publishing (src/server/publish/settings.ts):

FieldDefaultMeaning
PublishingOffOff: the Publish page is hidden and nothing is posted. Posts already scheduled wait until it is on again.
YouTube channelsOnNeeds your Google OAuth client (below)
Postiz connectionsOnCustomers enter the address and API key of their own Postiz
Webhook channelsOnA signed POST with a link to the file and the post text
Uploads at the same time21 to 6. More uploads at once use more bandwidth while clips render.
YouTube upload chunk (MB)81 to 64. A file is sent in pieces straight from disk; after a network error only the missing part is sent again.

The plan limit "Posts published or scheduled per month" (0 = no limit) is on the Plans page. Two status checks watch it: "YouTube publishing is set up" and "Scheduled posts are going out".

03YouTube: set up your Google OAuth client

You do this once, as the operator. Customers then connect their own channels with a click.

  1. Public URL The console's Server section (or PUBLIC_URL) must hold your https address. Google sends people back to it.
  2. Project and API In the Google Cloud console create a project (or pick one). APIs & Services, Library: enable YouTube Data API v3.
  3. Consent screen APIs & Services, OAuth consent screen: user type External; app name, support e-mail, your domain. Add the two scopes below.
  4. OAuth client APIs & Services, Credentials, Create credentials, OAuth client ID, type Web application. Add the authorised redirect URI below, exactly.
  5. Paste the client Console, System, Integrations: "Google OAuth client ID (YouTube publishing)" and "Google OAuth client secret (YouTube publishing)".
  6. Switch on Console, Product, Publishing: "Publishing". The status check "YouTube publishing is set up" then shows the redirect URI it expects.

The authorised redirect URI (replace the domain with yours, nothing else):

https://clips.your-agency.com/api/publish/youtube/callback

The scopes:

https://www.googleapis.com/auth/youtube.upload
https://www.googleapis.com/auth/youtube.readonly

youtube.upload uploads the video; youtube.readonly reads the name of the connected channel. The connection uses the OAuth authorization code flow with PKCE; the refresh token is sealed with ADMIN_SECRET_KEY, and no route ever returns a token (src/server/publish/youtube.ts).

Testing mode

While your consent screen's publishing status is Testing, only the test users you list can connect, and Google issues a refresh token that expires in 7 days, so every connection ends after a week. Publish the app in the Google Cloud console, and complete Google's verification for the YouTube scopes, before customers use it. Source: Google, OAuth 2.0, refresh token expiration.

04YouTube limits and facts to tell customers

Checked against Google's pages on 2026-11-10. Google can change them; the linked pages are the authority.

FactSource
Uploads have their own quota: by default about 100 uploads a day for your whole Google project, shared by everyone who posts from your install. Ask Google for more under Quotas.videos.insert
YouTube also has its own daily upload limit per channel, separate from your project's quota. The app reports it as the channel's limit when it is hit.API errors
Since 2 November 2026 uploads from projects that have not passed Google's audit are no longer forced to private. The app always sends the visibility the person chose and shows a note on a post if YouTube returns another one.Revision history
A vertical or square video of up to three minutes is shown as a Short automatically. There is no separate switch.YouTube Help

Title up to 100 characters, description up to 5,000 bytes, tags up to 500 characters. "Made for kids" is always declared, "no" by default. Custom thumbnails, playlists and caption uploads are not part of version 1.0.0.

05Postiz (each customer does this)

Postiz is a separate social scheduling tool that the customer runs or subscribes to themselves. Vodmill contains none of its code and only calls its public API.

  1. Connect accounts in Postiz TikTok, Instagram, X, LinkedIn, Facebook and so on.
  2. Create a key Postiz, System, Public API: create an API key.
  3. Connect in the app Publish, Channels, Connect Postiz: the Postiz server's public https address (or https://api.postiz.com for Postiz cloud) and the key. The accounts appear as places to post.
  • The Postiz server must be reachable on https (port 443) from the internet, and it must be able to fetch files from your install's public URL. A Postiz only reachable inside a private network cannot be connected.
  • A Postiz version with the upload-from-url endpoint is required.
  • Vodmill keeps the schedule itself and hands the post to Postiz when it is due. Postiz has its own request limits; see its API documentation.

The Postiz hand-off was written against Postiz's published API documentation and has not been run against a live Postiz for this release.

06Webhook channel (each customer does this)

Publish, Channels, Add a webhook: the receiver's https address, a name, and the signing secret shown in the form. Copy the secret into the receiver; it is not shown again. The address itself is stored sealed, and only its host is shown afterwards.

When a post is due, the receiver gets one POST:

{
  "id": "…",
  "type": "publish.post",
  "created": "2026-11-10T09:00:00.000Z",
  "workspace": "…",
  "data": {
    "publication": { "id": "…", "title": "…", "description": "…", "hashtags": ["…"], "scheduledAt": 1794301200000 },
    "file": { "url": "https://…", "name": "…", "bytes": 12345678, "aspect": "9:16", "contentType": "video/mp4", "expiresAt": 1794387600000 },
    "render": { "id": "…" }
  }
}

Headers: x-studio-event: publish.post and x-studio-signature: t=<unix seconds>,v1=<hex>. The file link works for 24 hours without a key. A 2xx answer means handed over; 5xx and 429 are retried with a growing delay; other answers fail the post. The "test" request has the type publish.ping.

Verify the signature

v1 is the HMAC-SHA256, as hex, of t + "." + raw body with the channel's signing secret. A complete receiver in Node.js (tested against version 1.0.0):

import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET;   // the signing secret shown once in the form

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;           // older than 5 minutes: a replay
  const want = Buffer.from(createHmac("sha256", secret).update(t + "." + rawBody).digest("hex"));
  const got = Buffer.from(parts.v1);
  return want.length === got.length && timingSafeEqual(want, got);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks).toString("utf8");              // verify the raw body, before JSON.parse
    if (!verify(raw, req.headers["x-studio-signature"], SECRET)) {
      res.writeHead(401).end("bad signature");
      return;
    }
    const event = JSON.parse(raw);
    if (event.type === "publish.post") {
      // fetch event.data.file.url within 24 hours and post it where you want
    }
    res.writeHead(200).end("ok");
  });
}).listen(3000);

In Zapier, Make or n8n, use their "Webhook" trigger for the address and a code step for the check above.

07Posting and scheduling in the app

  • Channels: workspace owners connect, rename and disconnect channels. Disconnecting a YouTube channel also asks Google to revoke the access.
  • Post or schedule: editors pick a finished render, a channel, the title, description and hashtags, the visibility for YouTube, and "now" or a time.
  • Queue: every post with its state: scheduled, publishing, posted, failed or canceled. Editors can edit, cancel, retry or reschedule.

What happens when something goes wrong (src/server/publish/sweep.ts):

  • Server errors, rate limits and lost connections are retried after 1, 2, 4, 8 and 16 minutes. After 6 tries the post fails with the last answer.
  • A used-up quota, the channel's own upload limit, a refused sign-in, a refused Postiz key, a missing file or a rejected title fail at once: a person has to fix them.
  • If the server restarts in the middle of a YouTube upload, the upload continues from where it stopped.
  • A YouTube channel whose access was withdrawn shows "reconnect"; connecting again repairs it.
  • Canceling while an upload runs stops it at the next piece. An upload that already finished cannot be taken back.

The person who created a post gets a notification when it is posted or fails, and the workspace's API webhooks receive publication.posted or publication.failed (Webhooks).