Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rustiz documentation

Rustiz is a headless, agent-first social-media scheduler: one Rust binary, one SQLite file and no GUI. It is a Rust port of Postiz v1.47.0, licensed AGPL-3.0. Agents drive it through MCP or the CLI; a human only opens the platform login page when OAuth requires one.

Status: the port is complete, but no provider has been tested against a real platform. All provider tests use mock servers. Expect to debug the first real connection to each platform. See Porting status for the porting tracker.

Security note: JWT_SECRET encrypts customFields credentials and custom-instance OAuth client data. Provider access tokens in the Integration.token / refreshToken columns are not encrypted in this port; they are plain text in SQLite. Encrypt the database volume and every backup, and restrict access to the database file as you would to a password store. See Self-hosting §3.2.

Documentation map

DocumentWhat it covers
Quick startBuild, init, connect an agent and post, in five minutes.
MCP setupMCP setup for Claude Desktop, Claude Code, Cursor and remote clients; the 31 tools; the posting flow.
CLIEvery CLI command, local vs remote mode, JSON output and exit codes.
REST API/public/v1 endpoints, auth, rate limits and errors.
BenchmarksThe measured numbers used on the website, and how to reproduce them.
Self-hostingInstall (local, systemd, Docker), every core environment variable, storage/S3/R2, ffmpeg, reverse proxies/TLS, OAuth callbacks, organizations/API keys, stdio vs remote MCP, backups and troubleshooting.
./providers/One page per provider: exact developer-app setup, scopes, redirect URI, customFields/is_between_steps follow-ups, post settings, media rules and implementation gotchas.
Project READMEProject overview, measured performance, CLI/MCP quick reference, rate limits.
Porting statusWhat was ported, what was removed, and known differences from Postiz.

Start in five commands

cargo build --release --locked -p rustiz
./target/release/rustiz init
./target/release/rustiz providers --status
./target/release/rustiz channels connect x --open
./target/release/rustiz serve

rustiz init prints an mcpServers block for a local agent; rustiz serve exposes the REST API at /public/v1, MCP at /mcp and the OAuth callbacks at /integrations/social/<provider>. The CLI works without a server when RUSTIZ_URL is unset, but posts only publish while rustiz serve, rustiz worker or rustiz mcp is running.

How a channel connects

connection is the value integrationSchema and GET /public/v1/providers report. It tells an agent what a human must do.

ModeMeaningExamples
oauthThe server returns a login URL; a human authorizes; the platform redirects to FRONTEND_URL/integrations/social/<identifier>.X, LinkedIn, Reddit, YouTube, Mastodon
customFieldsThe agent passes a JSON credential object; no browser and no server-side app. Credentials are encrypted at rest with JWT_SECRET.Bluesky, Medium, WordPress, Lemmy, Nostr
externalUrlThe app is registered on the user’s self-hosted instance; the server calls the instance’s /api/v1/apps and stores the returned client.mastodon-custom
telegramBotA human sends /connect <word> to the deployment’s bot, or the agent supplies the numeric chatId.Telegram
moltbookClaimThe server registers an agent; a human opens the claim URL; the agent polls.Moltbook
signerThe server creates a Neynar signer; a human approves it in the Farcaster app; the agent polls.Farcaster (wrapcast)

OAuth callbacks always use:

{FRONTEND_URL}/integrations/social/{identifier}

FRONTEND_URL has any trailing slash removed. Exceptions are called out in the master table: mastodon-custom reuses the mastodon path, tiktok-business requires a trailing slash, and x can use X_URL as its callback base.

When FRONTEND_URL does not contain the substring https (a plain-http local install), threads, slack, vk, tiktok, tiktok-business and instagram-standalone wrap the callback as https://redirectmeto.com/<FRONTEND_URL>/integrations/social/... because those platforms refuse an http:// redirect. In production, use HTTPS and the wrapper disappears.

Provider setup tiers

The tiers describe how much platform-side work stands between a fresh install and a working channel. They are about setup, not about how many features a provider supports; per-provider capabilities are on each provider page.

Tier 0 — zero developer app

Nothing to register on the platform and no server environment variable: the credentials come from the channel dialog (customFields), the instance registers the OAuth app itself (externalUrl), or the flow is a claim (moltbookClaim).

ProviderIdentifierWhat the user supplies
BlueskyblueskyService URL (default https://bsky.social), handle, app password
Dev.todevtoAPI key
HashnodehashnodeAPI key
LemmylemmyInstance URL, username, password
ListMonklistmonkURL, username, password
M. Instancemastodon-customInstance URL; the server registers an OAuth app on it
MediummediumAPI key
MoltbookmoltbookNothing; the server registers an agent and a human claims it
Nostrnostr64-character hex private key (nsec is not accepted)
WordPresswordpressSite URL, username, application password

Tier 1 — instant self-serve app or bot

You create an app or bot in a self-service portal, paste a client id/secret (or bot token), register the redirect URI and connect immediately. No platform review is needed for a private/self-hosted app; Slack and Discord only review apps that are publicly distributed.

ProviderIdentifierWhat you create
DiscorddiscordApplication + bot; client id/secret and bot token
KickkickDeveloper app (OAuth 2.1 + PKCE)
MastodonmastodonOAuth app on the instance configured in MASTODON_URL (POST /api/v1/apps); use mastodon-custom for arbitrary instances
SlackslackSlack app with bot scopes and an icon
TelegramtelegramBot from BotFather
TumblrtumblrOAuth app
TwitchtwitchDeveloper console app
VKvkVK ID app
WhopwhopOAuth app (public client, PKCE)

Tier 2 — sandbox/testing mode first, production review or verification

The app works immediately for accounts you add as testers/developers, but publishing to the public — or using the full API — requires app review, business verification, an audit, or a paid plan. Budget days to weeks, not minutes.

ProviderIdentifierGate
DribbbledribbbleApplication registration/review; scoped tokens
Facebook PagefacebookMeta app in Development mode works for roles/testers; Live mode needs business verification and advanced permissions
Instagram (Facebook Business)instagramSame Meta app; professional account linked to a Facebook Page; advanced permissions
Instagram (Standalone)instagram-standaloneSeparate Instagram app; professional account; testers until review
LinkedInlinkedinProducts: Share on LinkedIn, Sign In with LinkedIn (OIDC), Advertising API; the org scopes need the Advertising API request
LinkedIn Pagelinkedin-pageSame app and scopes; must administer a company page
PinterestpinterestTrial access works for the app owner; standard access is reviewed
RedditredditWeb app is self-serve, but API access is gated by Reddit’s Responsible Builder review; commercial use is a separate contract
ThreadsthreadsMeta app + Threads API use case; tester invites until the app is live
TikToktiktokDeveloper app + Content Posting API; unaudited Direct Post is private and limited to a few users/day; audit unlocks privacy levels
XxOAuth 1.0a app works on the free tier with strict monthly write limits; production volume needs a paid API tier
YouTubeyoutubeGoogle Cloud project + OAuth consent screen; test users work immediately, public use needs verification; brand accounts need admin trust

Tier 3 — paid, partner or special-access APIs

ProviderIdentifierGate
Google My BusinessgmbGoogle My Business API access request (days to a week) and a verified Business Profile
MeWemeweMeWe Developer Program is in beta; applications are reviewed and spots are limited
TikTok Businesstiktok-businessTikTok Business/Ads developer app and business account; separate credentials and a trailing-slash redirect URI
FarcasterwrapcastNeynar account, client id, app FID + mnemonic + secret key; Neynar plan limits and signer approval

Platform rules change frequently. If a portal does not match these notes, the portal is authoritative; the code-side contract (identifier, env var names, redirect path, scopes) is in the provider page and in the Rust source.

Master provider table

All 35 registered providers, alphabetically by identifier. — means the provider needs no server environment variable (the credentials arrive through customFields, the instance, or a claim).

Legend: * = callback is tunnelled through redirectmeto.com when FRONTEND_URL is not HTTPS. † = trailing slash is required. ‡ = shares credentials with another provider.

Display nameIdentifierConnectionRequired server env varsRedirect URI path
BlueskyblueskycustomFields—— (no browser redirect)
Dev.todevtocustomFields——
DiscorddiscordoauthDISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET (set DISCORD_BOT_TOKEN_ID too; posting uses the bot token)/integrations/social/discord
DribbbledribbbleoauthDRIBBBLE_CLIENT_ID, DRIBBBLE_CLIENT_SECRET/integrations/social/dribbble
Facebook PagefacebookoauthFACEBOOK_APP_ID, FACEBOOK_APP_SECRET ‡/integrations/social/facebook
Google My BusinessgmboauthGOOGLE_GMB_CLIENT_ID + GOOGLE_GMB_CLIENT_SECRET, or YOUTUBE_CLIENT_ID + YOUTUBE_CLIENT_SECRET ‡/integrations/social/gmb
HashnodehashnodecustomFields——
Instagram (Facebook Business)instagramoauthFACEBOOK_APP_ID, FACEBOOK_APP_SECRET ‡/integrations/social/instagram
Instagram (Standalone)instagram-standaloneoauthINSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET/integrations/social/instagram-standalone *
KickkickoauthKICK_CLIENT_ID, KICK_SECRET/integrations/social/kick
LemmylemmycustomFields——
LinkedInlinkedinoauthLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET ‡/integrations/social/linkedin
LinkedIn Pagelinkedin-pageoauthLINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET ‡/integrations/social/linkedin-page
ListMonklistmonkcustomFields——
MastodonmastodonoauthMASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET (optional MASTODON_URL, default https://mastodon.social)/integrations/social/mastodon
M. Instancemastodon-customexternalUrl—/integrations/social/mastodon (same route)
MediummediumcustomFields——
MeWemeweoauthMEWE_APP_ID, MEWE_API_KEY/integrations/social/mewe
MoltbookmoltbookmoltbookClaim——
NostrnostrcustomFields——
PinterestpinterestoauthPINTEREST_CLIENT_ID, PINTEREST_CLIENT_SECRET/integrations/social/pinterest
RedditredditoauthREDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET/integrations/social/reddit
SlackslackoauthSLACK_ID, SLACK_SECRET/integrations/social/slack *
TelegramtelegramtelegramBotTELEGRAM_TOKEN (optional TELEGRAM_BOT_NAME)— (bot command, no redirect)
ThreadsthreadsoauthTHREADS_APP_ID, THREADS_APP_SECRET/integrations/social/threads *
TiktoktiktokoauthTIKTOK_CLIENT_ID, TIKTOK_CLIENT_SECRET/integrations/social/tiktok *
TikTok (Business)tiktok-businessoauthTIKTOK_BUSINESS_CLIENT_ID, TIKTOK_BUSINESS_CLIENT_SECRET/integrations/social/tiktok-business/ *†
TumblrtumblroauthTUMBLR_CLIENT_ID, TUMBLR_CLIENT_SECRET/integrations/social/tumblr
TwitchtwitchoauthTWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET/integrations/social/twitch
VKvkoauthVK_ID/integrations/social/vk *
WhopwhopoauthWHOP_CLIENT_ID/integrations/social/whop
WordPresswordpresscustomFields——
FarcasterwrapcastsignerNEYNAR_CLIENT_ID, NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC, NEYNAR_SECRET_KEY— (signer approval, no redirect)
XxoauthX_API_KEY, X_API_SECRET/integrations/social/x (or X_URL base)
YouTubeyoutubeoauthYOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET/integrations/social/youtube

Shared credentials and naming traps

TrapDetail
Meta appfacebook, instagram use FACEBOOK_APP_ID / FACEBOOK_APP_SECRET. instagram-standalone uses a separate INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET. Threads uses its own THREADS_APP_ID / THREADS_APP_SECRET.
Google appgmb prefers GOOGLE_GMB_CLIENT_ID / GOOGLE_GMB_CLIENT_SECRET; a missing GMB id falls back to YOUTUBE_CLIENT_ID and a missing GMB secret to YOUTUBE_CLIENT_SECRET, so use one complete pair.
LinkedIn applinkedin and linkedin-page share one app and the same scope set; the organization scopes are required for the personal provider too.
Dribbble spellingThe env vars are DRIBBBLE_CLIENT_ID / DRIBBBLE_CLIENT_SECRET and the callback is /integrations/social/dribbble (two Bs). Some Postiz docs say DRIBBLE_* / .../dribble; those are typos.
Kick secretThe secret variable is KICK_SECRET, not KICK_CLIENT_SECRET.
Discord botDISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET make the provider “configured”, but posting uses DISCORD_BOT_TOKEN_ID; without it channel messages fail.
TikTok vs TikTok BusinessNever reuse one app’s credentials for the other: tiktok-business is a different API host, scope set and redirect URI.
Identifier vs namewrapcast is Farcaster, gmb is Google My Business, mastodon-custom is “M. Instance”, instagram-standalone is the standalone login. These identifiers are also the doc filenames and the platform argument to integrationSchema.

Connecting from an agent

REST

curl -sS -X POST https://social.example.com/public/v1/integrations/x/connect \
  -H "Authorization: Bearer $RUSTIZ_API_KEY" \
  -H 'content-type: application/json' -d '{}'

The response is either {connected:true,...} or {connected:false,pending:true,connectionId,url,message}. Poll GET /public/v1/integrations/connect/<connectionId> (or connectChannelTool with that id) until it reports connected, then read GET /public/v1/integrations for the channel id.

MCP

connectChannelTool arguments:

FieldNotes
providerIdentifier from the table above. Omit when polling connectionId.
connectionIdPoll a pending OAuth/Telegram/Moltbook/Farcaster connection.
externalUrlSelf-hosted instance URL; required for mastodon-custom.
customFieldsCredentials for customFields providers. Telegram also accepts {"chatId":"-100..."}; Moltbook {"apiKey":"..."}; Farcaster {"signerUuid":"..."}.
nameMoltbook agent name (required for a new claim).
descriptionMoltbook agent description (optional).

customFields takes precedence over OAuth for any provider when it is a JSON object, so only send it to providers whose integrationSchema lists customFields.

For customFields providers, the keys and validation regexes are returned by integrationSchema (the same data the provider’s custom_fields() builds); the provider page reproduces them. Credentials are encrypted with JWT_SECRET and never returned.

Channels with inBetweenSteps (Facebook pages, Instagram accounts, LinkedIn companies, YouTube channels, Google Business locations, Tumblr blogs) need a second call:

triggerTool       { integrationId, methodName: "pages", dataSchema: [] }
channelSetupTool  { integrationId, data: { "page": "<id>" } }

methodName must be one of the provider’s declared functions (pages, companies, boards, subreddits, channels, blogs, locations, …). The provider page lists the exact follow-up for each.

Provider page checklist

Every provider page in ./providers/ follows the same shape:

  1. Overview table — identifier, display name, connection mode, capabilities (comments/threads, pending/async publishing, analytics, post analytics, mentions, is_between_steps, convert_to_jpeg, max_concurrent_job, character limit).
  2. Server environment variables — required_env() / required_env_alternatives(), plus optional <PLATFORM>_BASE_URL overrides.
  3. Developer-app setup — portal URL, products/APIs, exact redirect URI (including redirectmeto.com and trailing-slash quirks), exact scopes requested and checked by check_scopes(), and sandbox/test-user vs production review.
  4. Connecting a channel — CLI rustiz channels connect <id> and MCP connectChannelTool, including customFields keys/regexes or triggerTool/channelSetupTool follow-ups.
  5. Post settings and media validation — settings_schema(), validate_settings(), check_validity(), media requirements and plug functions (run_plug / run_post_plug).
  6. Implementation notes — token refresh, rate limits and port-specific quirks.

If a page and the Rust code disagree, the Rust code wins. The provider’s identifier() and ProviderInfo live in crates/rustiz-providers/src/<module>.rs; the connect flows are in crates/rustiz-server/src/connect.rs and oauth.rs.

Quick start

Rustiz is one binary. Build it, run init once, and point an agent at it. This page gets a local install working in about five minutes. For servers, Docker and TLS, see Self-hosting.

Alpha. Every provider is ported and tested against mock servers, but none has been tested against the live platform yet. Expect to debug the first real connection to each platform.

Source not public yet. The git clone URL below is a placeholder until the public release, so that command won’t work until the repository is published. The rest of this page describes a working local build once the source is available. The site’s waitlist is a disconnected preview for now; once it’s connected, choose “Self-hosting only” to be notified when the repository opens.

1. Build

You need a stable Rust toolchain. SQLite is built in. ffmpeg is optional and only used for video thumbnails.

git clone <repository-url> rustiz && cd rustiz
cargo build --release --locked -p rustiz
sudo install -m 0755 target/release/rustiz /usr/local/bin/rustiz   # optional

2. Initialise

rustiz init

This creates ~/.rustiz/ with a private config.env (holding JWT_SECRET, DATABASE_URL, FRONTEND_URL and RUSTIZ_API_KEY), the SQLite database and the first organization. It prints a JSON document that includes an mcpServers block for your agent. Running init again never replaces the secret or creates a second organization.

Back up JWT_SECRET together with the database. Stored channel credentials can’t be decrypted without it.

3. Check which platforms are ready

rustiz providers --status

Each provider reports whether it is configured, the names of any missing environment variables, and how a channel connects (oauth, customFields, telegramBot, …). Credential providers such as Bluesky, Dev.to, Medium, WordPress or Nostr need no server configuration at all. OAuth platforms need a developer app; each provider page walks through it.

4. Connect your agent

Local agent over stdio (Claude Desktop, Claude Code, Cursor, …): paste the block init printed. It looks like this and contains no secret:

{
  "mcpServers": {
    "rustiz": { "command": "/usr/local/bin/rustiz", "args": ["mcp"] }
  }
}

rustiz mcp runs the scheduler in the same process and, for a local FRONTEND_URL, also receives the platform’s login redirect. You don’t need a separate server. See MCP setup for client-specific instructions.

Or run the server for REST, remote MCP and the scheduler:

rustiz serve          # http://localhost:5000
curl http://localhost:5000/health   # -> ok

5. Connect an account and post

From the CLI:

rustiz channels connect bluesky --fields ./bluesky.json   # {"service":"https://bsky.social","identifier":"you.bsky.social","password":"app-password"}
rustiz channels connect x --open                          # after setting X_API_KEY / X_API_SECRET; opens the X login
rustiz channels list
rustiz posts create --channel <channel-id> \
  --content "<p>Hello from Rustiz</p>" \
  --date 2030-01-01T09:00:00Z --type schedule

Or ask your agent: “Connect my Bluesky account and schedule a post for tomorrow at 9:00 UTC.” It will call providersStatusTool, connectChannelTool, validatePostTool and schedulePostTool.

Posts only publish while rustiz serve, rustiz worker or rustiz mcp is running.

Next

Install: binary, systemd and Docker

Rustiz is a headless, agent-first social-media scheduler: one Rust binary, one SQLite database, no web GUI, no Redis and no Postgres. The same process serves the REST API (/public/v1), the MCP endpoint (/mcp), the OAuth “connect a channel” callbacks and the scheduler.

This document is the operator reference: install paths, every environment variable the core reads, storage and ffmpeg, reverse proxies and TLS, organizations/API keys and connecting agents over stdio or remote MCP.

Reality check. The port is complete but, as of this writing, no provider has been tested against a real platform. Provider tests use mock servers. Treat the first real connection to every platform as a debugging session. See Porting status and the per-provider pages in ./providers/.


1. Quickstart

PathTLSDatabase / mediaTypical use
Local binary (§1.1)none, plain HTTP on loopback~/.rustiz/rustiz.db + ~/.rustiz configEvaluating, one agent on one machine
Production binary + systemd (§1.2)terminated by Caddy/Nginx/Cloudflare/var/lib/rustiz/rustiz.db + /var/lib/rustiz/uploads (or S3/R2)A real deployment with a public HTTPS origin
Docker (§1.3)terminated by the host proxynamed volume /dataReproducible deployment or a container platform

1.1 Local, single user (no TLS)

Requirements: a Rust toolchain (stable), and optionally ffmpeg for video thumbnails. SQLite is embedded.

cargo build --release --locked -p rustiz
./target/release/rustiz init
./target/release/rustiz providers --status
./target/release/rustiz serve

rustiz init creates:

  • ~/.rustiz/ (mode 0700 on Unix)
  • ~/.rustiz/config.env (mode 0600) containing JWT_SECRET, DATABASE_URL, FRONTEND_URL and RUSTIZ_API_KEY
  • ~/.rustiz/rustiz.db (SQLite, WAL mode)
  • the first organization

It prints one JSON document with the paths, the organization, and an mcpServers block to paste into an agent. When it creates an organization it also prints the API key; when it adopts an existing one it prints api_key_location instead and never replaces the existing secret or creates a second organization.

Then:

  • REST API: http://localhost:5000/public/v1/...
  • MCP over HTTP: http://localhost:5000/mcp (Bearer API key)
  • MCP over stdio: rustiz mcp
  • Health: http://localhost:5000/health → ok

To use another directory (for example a system service):

sudo rustiz init --dir /var/lib/rustiz --name production

init writes the config file but does not persist shell environment variables. In particular, if you run FRONTEND_URL=https://social.example.com rustiz init, the generated config.env still contains FRONTEND_URL=http://localhost:5000; the same applies to DATABASE_URL. Edit the file (or export the variable on every start) before relying on it (see §3.1).

1.2 Production server (systemd + reverse proxy)

Do not expose the plain-HTTP process directly. Put Caddy, Nginx or Cloudflare in front; TLS terminates there.

sudo install -m 0755 target/release/rustiz /usr/local/bin/rustiz
sudo useradd --system --home /var/lib/rustiz --shell /usr/sbin/nologin rustiz
sudo install -d -o rustiz -g rustiz -m 0700 /var/lib/rustiz
sudo -u rustiz /usr/local/bin/rustiz init --dir /var/lib/rustiz --name production
sudo install -d -o rustiz -g rustiz -m 0700 /var/lib/rustiz/uploads
sudoedit /var/lib/rustiz/config.env

Set at least:

FRONTEND_URL=https://social.example.com
DATABASE_URL=sqlite:///var/lib/rustiz/rustiz.db
UPLOAD_DIRECTORY=/var/lib/rustiz/uploads
JWT_SECRET=<keep the value init generated>
RUSTIZ_API_KEY=<keep the value init generated>
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_...
EMAIL_FROM_ADDRESS=notifications@example.com
EMAIL_FROM_NAME=Rustiz
TRUSTED_PROXY_HOPS=1

A systemd unit:

# /etc/systemd/system/rustiz.service
[Unit]
Description=Rustiz (API + MCP + scheduler)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=rustiz
Group=rustiz
WorkingDirectory=/var/lib/rustiz
Environment=RUSTIZ_CONFIG=/var/lib/rustiz/config.env
ExecStart=/usr/local/bin/rustiz serve
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rustiz

[Install]
WantedBy=multi-user.target

If you want a separate scheduler process (for example API replicas that must not each run jobs):

# /etc/systemd/system/rustiz-worker.service
[Unit]
Description=Rustiz scheduler
After=rustiz.service
[Service]
Type=simple
User=rustiz
Group=rustiz
Environment=RUSTIZ_CONFIG=/var/lib/rustiz/config.env
ExecStart=/usr/local/bin/rustiz worker
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

and start the HTTP tier with rustiz serve --no-worker.

1.3 Docker

The repository Dockerfile builds the binary in rust:1-slim and copies it to gcr.io/distroless/cc-debian12. The runtime image has no shell, no package manager and no ffmpeg (see §5.4).

docker build -t rustiz:local .

# One-time: create the volume, config file and database.
docker volume create rustiz-data
docker run --rm -v rustiz-data:/data rustiz:local init --dir /data

# Run the server.
docker run -d --name rustiz \
  -p 127.0.0.1:5000:5000 \
  -v rustiz-data:/data \
  -e RUSTIZ_CONFIG=/data/config.env \
  -e FRONTEND_URL=https://social.example.com \
  rustiz:local

The image already sets:

DATABASE_URL=sqlite:///data/rustiz.db
UPLOAD_DIRECTORY=/data/uploads
PORT=5000

RUSTIZ_CONFIG=/data/config.env is required if you rely on the file init wrote: without it the binary looks for ~/.rustiz/config.env and will not see the JWT_SECRET created there. Alternatively pass JWT_SECRET and RUSTIZ_API_KEY as environment variables.

A minimal Compose file:

services:
  rustiz:
    build: .
    restart: unless-stopped
    ports:
      - "127.0.0.1:5000:5000"
    environment:
      RUSTIZ_CONFIG: /data/config.env
      FRONTEND_URL: https://social.example.com
      TRUSTED_PROXY_HOPS: "1"
    volumes:
      - rustiz-data:/data

volumes:
  rustiz-data:

Run docker compose run --rm rustiz init --dir /data once before the first up. The distroless image has no shell, so docker exec ... sh will not work; use logs and the /health endpoint instead. If you run the container as a non-root user, chown the volume first.


2. What runs where

CommandHTTP APIMCPSchedulerOAuth callback receiver
rustiz serveyesyes (/mcp)yes (unless --no-worker)yes
rustiz serve --no-workeryesyesnoyes
rustiz workernonoyesno
rustiz mcpnostdio onlyyesyes, on 127.0.0.1:<FRONTEND_URL port> when the URL is plain HTTP on localhost
rustiz initnononono
rustiz org ...nononono
rustiz providersnononono
rustiz migratenononono

The HTTP server and the worker share only the SQLite database. Jobs are claimed with a five-minute lease and a heartbeat every 100 seconds, so a separate rustiz worker and rustiz serve can coexist. SQLite still has one writer at a time; for anything beyond a small deployment, run one worker and one API process, and use WORKER_CONCURRENCY to parallelize inside them.

rustiz with no subcommand is equivalent to rustiz serve.

In-process CLI commands (posts, channels, media, analytics, notifications, find-slot) run the HTTP router against the local database but do not start the scheduler. A post created with --type schedule stays queued until rustiz serve, rustiz worker or rustiz mcp is running.


Configuration and environment variables

3. Configuration model

3.1 Precedence and the config file

At startup, before clap parses arguments, rustiz loads a dotenv-style file:

  1. --config <path>
  2. RUSTIZ_CONFIG=<path>
  3. ~/.rustiz/config.env (or %USERPROFILE%\.rustiz\config.env)

A file named explicitly by --config or RUSTIZ_CONFIG that does not exist is a fatal error. A missing default file is fine. rustiz init should be pointed at a directory with --dir, not at a not-yet-existing --config file, because the config file is loaded before the command runs.

The file is loaded before clap parses the command line, so PORT, HOST, DATABASE_URL and RUSTIZ_API_KEY written there are visible to the CLI as normal environment variables.

Variables already present in the real environment are not overwritten by the file. An exported variable wins even when it is empty; an empty value is then treated as unset by the application. To change a setting permanently, edit the file; to override it for one run, export it.

The parser accepts:

  • KEY=value, optional export KEY=value
  • # comments, blank lines
  • single quotes (literal), double quotes (\n, \r, \t, \\, \", \')
  • bare values, with an inline comment starting at whitespace + #
  • duplicate keys: the last one wins
  • a UTF-8 BOM at the start

On Unix, rustiz warns at every start if config.env is readable by group or others. Keep it 0600; it contains JWT_SECRET and the organization API key.

3.2 JWT_SECRET

JWT_SECRET is required by serve, worker and mcp; the process exits with JWT_SECRET must be set otherwise. rustiz init generates 64 random alphanumeric characters (about 380 bits).

It is not a session-signing key in this port. It is the passphrase for the AES-256-CBC (OpenSSL EVP_BytesToKey/MD5, byte-compatible with Postiz) layer that encrypts:

  • customFields credential JSON (stored in Integration.customInstanceDetails)
  • per-instance OAuth client information (customInstanceDetails) for externalUrl/custom-instance providers
  • the pending client_info of an externalUrl OAuth state row

It does not encrypt provider access tokens in this port: the Integration.token / Integration.refreshToken columns are plain text in the SQLite file. That includes OAuth access/refresh tokens and some API keys (for example a Moltbook agent key or a Telegram chat id). Protect the database file itself (file permissions, disk encryption, encrypted backups) and treat a copy of it as a credential dump.

Changing or losing JWT_SECRET makes the encrypted custom-credential channels unreadable (Bluesky, WordPress, Lemmy, Listmonk, Medium, Dev.to, Hashnode, Nostr, Mastodon custom instances, etc.), and those channels must be reconnected. OAuth channels keep working because their tokens are not encrypted with this secret, but reconnecting them is the cleanest response to a suspected secret compromise. There is no supported rotation procedure short of reconnecting every affected channel.

3.3 Database URL

SQLite URLs use the sqlx form:

sqlite:///absolute/path/rustiz.db   # absolute (three slashes)
sqlite://relative/rustiz.db         # relative to the process CWD
sqlite::memory:                     # tests only

rustiz init percent-encodes %, ? and # in the path. The database runs in WAL mode, synchronous=NORMAL, foreign keys on, busy timeout 10s, and up to 8 pooled connections. Migrations run automatically on every connection open; rustiz migrate is a convenience that opens the database, migrates and exits.

Because WAL mode writes rustiz.db-wal and rustiz.db-shm, a naive copy of only rustiz.db while the server runs can lose recent transactions. Use sqlite3 ... ".backup ..." or stop the process (§8.3).


4. Complete environment variable reference

This section covers variables read by the core, server, engine and CLI outside individual social providers. Provider credentials (X_API_KEY, FACEBOOK_APP_ID, …) are listed in the master provider table and on each provider page.

4.1 Core

VariableRequiredDefaultPurpose
FRONTEND_URLyes in productionhttp://localhost:5000Public origin as the browser/agent sees it. Base of every OAuth redirect URI, /uploads/... URLs, email links, MCP metadata and WWW-Authenticate. A trailing slash is removed. Changing it invalidates every registered redirect URI.
DATABASE_URLnosqlite://rustiz.dbSQLite URL. Docker image sets sqlite:///data/rustiz.db.
JWT_SECRETyes for serve/worker/mcpgenerated by initEncryption key for customFields credentials and custom-instance OAuth client data; provider access tokens are not encrypted with it. See §3.2.
PORTno5000HTTP listen port (serve only, via clap/env).
HOSTno0.0.0.0HTTP bind address. Use 127.0.0.1 behind a local reverse proxy.
ALLOW_PRIVATE_NETWORKnofalsetrue/1 disables the SSRF guard globally: outbound HTTP may resolve to loopback, private, link-local and metadata addresses. Only for a trusted single-tenant network.
UPLOAD_DIRECTORYfor local storageunsetDirectory for local media, and the root served at /uploads. Without it, local uploads fail and generated JPEGs/thumbnails cannot be stored.
MAX_UPLOAD_MBno1024Maximum request body in MiB for every route (not just multipart). Also cap it in the reverse proxy.
RUST_LOGnoinfo,sqlx=warn,tower_http=infotracing env filter. Logs go to stderr; stdout is reserved for the MCP stdio protocol.

4.2 CLI, MCP and config discovery

VariableRequiredDefaultPurpose
RUSTIZ_CONFIGno~/.rustiz/config.envConfig file to load before argument parsing.
RUSTIZ_URLnounsetCLI only: talk to this server instead of the local database. Must be an http(s):// origin, no /public/v1 suffix; the CLI appends it. Requires RUSTIZ_API_KEY. 300-second client timeout.
RUSTIZ_API_KEYwith RUSTIZ_URLfrom config fileOrganization API key for the CLI and for rustiz mcp.
HOME / USERPROFILEnoOS valueUsed to find ~/.rustiz.

With RUSTIZ_URL, the CLI only has the remote REST API. A channels connect OAuth flow cannot watch the server’s callback or detect the new channel; it prints the login URL and returns. Use local database mode with a loopback HTTP FRONTEND_URL if you want the CLI to wait for the login.

4.3 Storage

STORAGE_PROVIDER selects the backend: local (default), cloudflare (Cloudflare R2) or s3 (any S3-compatible store, including MinIO). An invalid value is a fatal error for the upload route and object-storage features.

VariableRequiredDefaultPurpose
STORAGE_PROVIDERnolocallocal, cloudflare or s3.

Local

VariableRequiredPurpose
UPLOAD_DIRECTORYyesWrites <yyyy>/<mm>/<dd>/<id>.<ext>; served at ${FRONTEND_URL}/uploads/.... Must be on persistent storage.

Cloudflare R2

VariableRequiredDefaultPurpose
CLOUDFLARE_ACCOUNT_IDyes—R2 account id; endpoint is built as https://<account>.r2.cloudflarestorage.com.
CLOUDFLARE_ACCESS_KEYyes—R2 access key id.
CLOUDFLARE_SECRET_ACCESS_KEYyes—R2 secret access key.
CLOUDFLARE_BUCKETNAMEyes—Bucket name.
CLOUDFLARE_REGIONnoautoR2 region.
CLOUDFLARE_BUCKET_URLyes—Public HTTPS base URL of the bucket/custom domain, without a trailing slash. Stored object URLs are <bucket-url>/<key>.

S3 and S3-compatible stores

Aliases are checked in the order shown; AWS_* wins over S3_* when both are set.

VariableRequiredDefaultPurpose
AWS_BUCKETNAME / S3_BUCKETNAMEyes—Bucket name.
AWS_REGION / S3_REGIONnous-east-1Region.
AWS_ENDPOINT / S3_ENDPOINTnoAWS defaultCustom endpoint (MinIO, Backblaze, etc.). An http:// endpoint enables plain HTTP for the object store.
AWS_ACCESS_KEY_ID / S3_ACCESS_KEY_IDnoenv/default chainAccess key.
AWS_SECRET_ACCESS_KEY / S3_SECRET_ACCESS_KEYnoenv/default chainSecret key.
AWS_SESSION_TOKEN / S3_SESSION_TOKENno—Session token for temporary credentials.
AWS_BUCKET_URL / S3_BUCKET_URLnoderivedPublic HTTPS base URL. If unset: AWS_ENDPOINT/<bucket> when an endpoint is set, otherwise https://<bucket>.s3.<region>.amazonaws.com.

Path-style requests are used (with_virtual_hosted_style_request(false)), which is what MinIO expects. The bucket (or a CDN in front of it) must serve objects publicly; the app does not generate signed URLs.

4.4 Email

Email is optional: with EMAIL_PROVIDER unset or unknown, nothing is sent and the process logs no email provider configured. Notification rows are still written to the database. To actually deliver, you also need both EMAIL_FROM_ADDRESS and EMAIL_FROM_NAME; a missing sender identity or an address without @ is skipped silently (a log line is emitted).

VariableRequiredDefaultPurpose
EMAIL_PROVIDERyes to sendunset = disabledresend, nodemailer/smtp, or ses. Any other value disables email.
EMAIL_FROM_ADDRESSyes—From: address. Verify the domain with Resend/SES/SMTP provider.
EMAIL_FROM_NAMEyes—From: display name.
EMAIL_RATE_INTERVAL_MSnopriority 1050 ms, bulk 1750 msMinimum spacing between queued sends. The default is 3×/5× the 350 ms base interval.

Resend

VariableRequiredDefaultPurpose
RESEND_API_KEYyes—Resend API key; sent as Authorization: Bearer.
RESEND_API_BASE_URLnohttps://api.resend.comBase URL override (tests/mocks). The path /emails is appended.

SMTP / nodemailer

VariableRequiredDefaultPurpose
EMAIL_HOSTyes—SMTP host.
EMAIL_PORTno587SMTP port.
EMAIL_SECUREnofalsetrue = implicit TLS (SMTPS); false = STARTTLS.
EMAIL_USERno—SMTP username. Without user+pass the transport is unauthenticated.
EMAIL_PASSno—SMTP password.

Amazon SES

EMAIL_PROVIDER=ses uses SES’s SMTP interface, not the SES API. If either SES_SMTP_HOST or SES_SMTP_USER is set, the SMTP aliases are used; otherwise the host is derived from the region and credentials fall back to the SES access key pair.

VariableRequiredDefaultPurpose
SES_REGIONyes unless SES_SMTP_HOST or SES_SMTP_USER is setus-east-1Builds email-smtp.<region>.amazonaws.com.
SES_SMTP_HOSTnoemail-smtp.<region>.amazonaws.comOverride SMTP host.
SES_SMTP_PORTno465SMTP port.
SES_SMTP_SECUREnotrueImplicit TLS; false = STARTTLS.
SES_SMTP_USER / SES_SMTP_USERNAMEconditional—SMTP username; falls back to SES_ACCESS_KEY.
SES_SMTP_PASS / SES_SMTP_PASSWORDconditional—SMTP password; falls back to SES_SECRET_ACCESS_KEY.
SES_ACCESS_KEYconditional—SES SMTP username alias.
SES_SECRET_ACCESS_KEYconditional—SES SMTP password alias.

Short-link replacement is opt-in per post (shortLink: true). The first configured provider wins in this exact order: Dub → Short.io → Kutt → LinkDrip. With none configured, links are left untouched.

VariableRequiredDefaultPurpose
DUB_TOKENfor Dub—Dub API token (Authorization: Bearer). Presence selects Dub.
DUB_API_ENDPOINTnohttps://api.dub.coDub API base.
DUB_API_ENDPOINT_BASE_URLno—Fallback for DUB_API_ENDPOINT (the code appends _BASE_URL).
DUB_SHORT_LINK_DOMAINnodub.shDomain used for links and for “already shortened” detection.
SHORT_IO_SECRET_KEYfor Short.io—Short.io secret key.
SHORT_IO_API_ENDPOINTnohttps://api.short.ioShort.io API base.
SHORT_IO_API_ENDPOINT_BASE_URLno—Fallback for SHORT_IO_API_ENDPOINT.
KUTT_API_KEYfor Kutt—Sent as X-API-Key.
KUTT_API_ENDPOINTnohttps://kutt.it/api/v2Kutt API base.
KUTT_API_ENDPOINT_BASE_URLno—Fallback for KUTT_API_ENDPOINT.
KUTT_SHORT_LINK_DOMAINnokutt.itKutt domain.
LINK_DRIP_API_KEYfor LinkDrip—Sent as Authorization: Bearer.
LINK_DRIP_API_ENDPOINTnohttps://api.linkdrip.com/v1LinkDrip API base.
LINK_DRIP_API_ENDPOINT_BASE_URLno—Fallback for LINK_DRIP_API_ENDPOINT.
LINK_DRIP_SHORT_LINK_DOMAINnodripl.inkLinkDrip domain.

Notes:

  • Short.io’s domain is hard-coded as short.io; it has no domain env.
  • Short.io statistics use a hard-coded https://statistics.short.io/... host; the link listing reads up to 10 pages of 150 links.
  • LinkDrip expansion returns an empty string and its click count is always "0"; only creation is implemented.
  • Dub sends the organization id as tenantId.

4.6 HTTP limits and proxies

VariableRequiredDefaultPurpose
API_LIMITno90Per-organization, per-hour limit for POST routes under /public/v1 whose path contains /posts (create post). 0 disables; invalid values fall back to 90 with a warning.
REQUEST_LIMITno10000Per-organization, per-hour limit for /mcp and the other /public/v1 routes. 0 disables; invalid values fall back.
TRUSTED_PROXY_HOPSno0Number of trusted proxies that append to X-Forwarded-For. 0 ignores XFF and uses the socket peer. With one Nginx/Caddy hop set 1; with Cloudflare + Nginx set 2. The code trusts the Nth address from the right without verifying that the peer is a proxy.

The limiter is a fixed one-hour window held in process memory. Counts are not shared between replicas and reset on restart. X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset are sent on allowed responses; a blocked response is 429 with Retry-After. MCP gets a JSON-RPC error body; REST gets {"statusCode":429,"message":"ThrottlerException: Too Many Requests"}.

OAuth credential endpoints have fixed per-IP limits that cannot be changed:

EndpointLimit
POST /oauth/authorize30/hour/address
POST /oauth/register30/hour/address
POST /oauth/token120/hour/address

4.7 MCP

VariableRequiredDefaultPurpose
MCP_ALLOWED_HOSTSnoFRONTEND_URL host, localhost, 127.0.0.1Comma-separated extra Host header values accepted by the MCP transport (DNS-rebinding protection). An entry without a port matches any port; host:port restricts it. Add [::1] for IPv6 loopback and the exact internal host if a proxy rewrites Host.
MCP_SUBSCRIPTIONS_POLL_MSno2000Poll interval for subscriptions/listen post-state hints. Clamped to 20–60000 ms.
MCP_SUBSCRIPTIONS_MAX_STREAMSno4Concurrent subscriptions/listen streams per organization. Clamped to 1–64.
MCP_SUBSCRIPTIONS_SEND_TIMEOUT_MSno10000How long a notification send may block before the slow stream is dropped. Clamped to 100–120000 ms.

subscriptions/listen reads the database, not an in-process bus, so it also sees changes made by a separate rustiz worker. Quick changes between polls collapse into the last one; deletions are not reported.

4.8 Worker, background jobs and media processing

VariableRequiredDefaultPurpose
WORKER_CONCURRENCYno32Global concurrent jobs per worker process. Per-provider semaphores apply on top (max_concurrent_job from the provider), and the internal main queue is capped at 4.
FFMPEG_BINARYnoffmpegffmpeg executable used for video thumbnails. It is probed with ffmpeg -version; absence is not an error.
AUTOPOST_INTERVAL_MSno3600000RSS autopost polling interval.
DIGEST_DELAY_MSno3600000Notification digest delay.
STREAK_REMINDER_MSno79200000 (22 h)When the streak reminder is queued.
STREAK_GRACE_MSno7200000 (2 h)Extra grace before a streak is considered broken.

4.9 Upload validation and compatibility

VariableRequiredDefaultPurpose
RESTRICT_UPLOAD_DOMAINSnounsetA single substring that media paths must contain in some providers’ post validation (X, Dev.to, Hashnode). It is not an allowlist for /public/v1/upload-from-url; that endpoint uses the SSRF guard instead. Despite the plural name, the implementation is a substring check.
DISABLE_SSRF_PROTECTIONnofalseBluesky-only compatibility flag: when true, the Bluesky service URL check accepts a non-public address. It does not disable the global HTTP guard; use ALLOW_PRIVATE_NETWORK for that (and only on a trusted network).
NEXT_PUBLIC_BACKEND_URLnounsetUsed by the Hashnode provider when it needs to build an absolute media URL. In this port it should be the same public origin as FRONTEND_URL.
NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORYnounsetUpload path prefix used with NEXT_PUBLIC_BACKEND_URL by the Hashnode provider (normally /uploads).

4.10 Provider credentials

Every OAuth/API credential is documented per provider. The shared groups that catch people out:

  • Facebook Page + Instagram (Facebook Business) use FACEBOOK_APP_ID / FACEBOOK_APP_SECRET, one Meta app.
  • LinkedIn + LinkedIn Page use LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET, one app and one scope set.
  • Google My Business accepts GOOGLE_GMB_CLIENT_ID / GOOGLE_GMB_CLIENT_SECRET; a missing GMB id falls back to YOUTUBE_CLIENT_ID, and a missing GMB secret to YOUTUBE_CLIENT_SECRET.

See Documentation overview.


MCP setup for agents

Rustiz serves the Model Context Protocol in two ways:

TransportCommand / URLAuthWhen to use
stdiorustiz mcpAPI key from config.env (or --api-key)The agent runs on the same machine
Streamable HTTPhttps://<your-host>/mcpAuthorization: Bearer <api-key>, or OAuth 2.1 + PKCERemote agents, Rustiz Cloud, several agents sharing one server

The server uses the official rmcp SDK. It implements spec revision 2026-07-28 and still accepts older clients that send initialize. Tool names match Postiz’s MCP server, so prompts written for Postiz keep working.

Operator details such as the OAuth endpoints, allowed hosts and token lifetimes are in Self-hosting §7.

Local (stdio)

Run rustiz init once. It prints the exact block for your machine:

{
  "mcpServers": {
    "rustiz": {
      "command": "/usr/local/bin/rustiz",
      "args": ["mcp"]
    }
  }
}

Use the absolute path: desktop apps don’t inherit your shell’s PATH. If you ran init --dir <path>, add "env": { "RUSTIZ_CONFIG": "<path>/config.env" }. stdout carries the protocol and diagnostics go to stderr.

Claude Desktop

Open Settings → Developer → Edit Config (on macOS the file is ~/Library/Application Support/Claude/claude_desktop_config.json), merge the block above into it, and restart Claude Desktop.

Claude Code

claude mcp add rustiz -- /usr/local/bin/rustiz mcp
# remote server:
claude mcp add --transport http rustiz https://social.example.com/mcp \
  --header "Authorization: Bearer $RUSTIZ_API_KEY"

Cursor

Add the block to ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project). For a remote server:

{
  "mcpServers": {
    "rustiz": {
      "url": "https://social.example.com/mcp",
      "headers": { "Authorization": "Bearer <api-key>" }
    }
  }
}

Other clients

Any MCP client that can launch a command (rustiz mcp) or speak Streamable HTTP works. For example, OpenAI Codex CLI in ~/.codex/config.toml:

[mcp_servers.rustiz]
command = "/usr/local/bin/rustiz"
args = ["mcp"]

Client config formats change often. If a snippet here doesn’t match your client’s documentation, follow the client’s documentation. The Rustiz side is always the same: rustiz mcp, or https://<host>/mcp with a Bearer key.

Remote (HTTP)

{
  "mcpServers": {
    "rustiz": {
      "type": "http",
      "url": "https://social.example.com/mcp",
      "headers": { "Authorization": "Bearer <api-key>" }
    }
  }
}

OAuth instead of a key in the config: leave out the header. A client that supports MCP authorization gets a 401 pointing at /.well-known/oauth-protected-resource/mcp, registers itself and opens a consent page that asks for the organization API key once. Access tokens last 1 hour and refresh tokens 30 days. Rotating the API key (rustiz org rotate-key <org-id>) revokes all of them.

If clients reach the server under a hostname other than FRONTEND_URL, add it to MCP_ALLOWED_HOSTS.

Tools

31 tools. The typical flow is in bold.

AreaTools
ChannelsprovidersStatusTool, integrationList, integrationSchema, connectChannelTool, triggerTool, channelSetupTool, channelSetTool, groupList, customersTool, mentionsTool
PostsvalidatePostTool, schedulePostTool, postStatusTool, postsListTool, updatePostTool, deletePostTool, findSlotTool, postSettingsTool
MediauploadFromUrlTool, mediaListTool, mediaDeleteTool
AnalyticsanalyticsTool, postAnalyticsTool, shortLinkStatsTool, notificationsListTool
WorkspacetagsTool, setsTool, signaturesTool, webhooksTool, plugsTool, autopostTool

How an agent schedules a post

providersStatusTool                     -> which platforms this server can connect
integrationList                         -> connected channels and their ids
integrationSchema { platform: "x" }     -> length limit, threads, settings, functions
validatePostTool  { socialPost: [...] } -> checks without creating anything
schedulePostTool  { idempotencyKey, socialPost: [...] }
postStatusTool    { group }             -> QUEUE / PUBLISHED / ERROR, URL, retryable?

A schedulePostTool call:

{
  "idempotencyKey": "launch-note-1",
  "socialPost": [
    {
      "integrationId": "<channel id>",
      "type": "schedule",
      "date": "2030-01-01T09:00:00Z",
      "postsAndComments": [
        { "content": "<p>We just shipped.</p>", "attachments": ["https://cdn.example.com/shot.png"] },
        { "content": "<p>Docs: https://example.com/docs</p>" }
      ],
      "settings": []
    }
  ]
}

postsAndComments[0] is the post and the rest are replies (a thread). Content is HTML: wrap each line in <p>.

Resending is safe. An identical call within 10 minutes, or the same idempotencyKey within 24 hours, returns the first result instead of posting again.

type: "now" returns a durable task handle to clients that declare the MCP Tasks extension. Other clients get a normal result and poll postStatusTool.

Live updates: subscriptions/listen on rustiz://posts/<id> or rustiz://groups/<id> sends a hint whenever a post’s state changes.

Connecting accounts from an agent

connectChannelTool handles every connection mode:

  • OAuth (X, LinkedIn, YouTube, …) returns {connected:false, pending:true, connectionId, url}. A human opens url once, and the agent polls with connectionId. Clients with URL elicitation get the link directly.
  • Credentials (Bluesky, Dev.to, WordPress, …): pass customFields, and the account connects immediately with no browser.
  • Telegram, Moltbook, Farcaster return an instruction for the human (a bot command, a claim URL, or a signer approval), then the agent polls.
  • Pages and companies (Facebook, Instagram, LinkedIn Page, YouTube, Google Business, Tumblr): finish with triggerTool and then channelSetupTool.

Each provider page lists the exact fields and follow-up steps.

CLI

rustiz is the server, the scheduler, the MCP server and a CLI for agents, all in one binary. Run rustiz --help or rustiz <command> --help for the complete, current reference.

Two modes

  • Local (default). Without --url, API commands run the same router in-process against DATABASE_URL. No server is needed. Posts are still only published while rustiz serve, rustiz worker or rustiz mcp is running somewhere against that database.
  • Remote. With --url (or RUSTIZ_URL) and RUSTIZ_API_KEY, the CLI talks to a server over HTTP, either your own or a hosted one.

API commands print exactly one JSON document on stdout. Diagnostics go to stderr.

Exit codeMeaning
0Success
1Server or runtime failure
2Bad usage or HTTP 400
3Authentication
4Not found
5Conflict: the post is being published

Global options

OptionEnvDefault
--config <path>RUSTIZ_CONFIG~/.rustiz/config.env
--database-url <url>DATABASE_URLsqlite://rustiz.db (init writes the real path)
--url <url>RUSTIZ_URLunset (local mode)
--api-key <key>RUSTIZ_API_KEYfrom the config file; never printed

Real environment variables override the config file.

Running

CommandPurpose
rustiz init [--dir <path>] [--name <org>]First-time setup: config file, database, first API key. Safe to rerun
rustiz serve [--port 5000] [--host 0.0.0.0] [--no-worker]HTTP API, MCP and the scheduler
rustiz workerThe scheduler only
rustiz mcp [--api-key <key>]MCP over stdio, with the scheduler and the local OAuth callback in-process
rustiz migrateApply database migrations and exit
rustiz providers [--status]List platforms; --status adds readiness as JSON
rustiz org create <name> · org list · org rotate-key <id>Organizations and keys (plain-text output). Rotating a key revokes MCP OAuth tokens

Channels

rustiz channels list
rustiz channels connect <provider> [--open] [--fields <file|->] [--external-url <url>] \
                                   [--name <n>] [--description <d>] [--timeout 300]
rustiz channels connect-status <connectionId>
rustiz channels delete <id>
  • OAuth providers print a login URL. In local mode the CLI also serves the platform callback on 127.0.0.1:<port of FRONTEND_URL> and waits until the account appears. --open launches the browser.
  • Credential providers take --fields (a JSON file, or - for stdin).
  • Telegram, Moltbook and Farcaster print what the human must do. Finish with connect-status.

Posts

rustiz posts list [--start <iso>] [--end <iso>] [--customer <id>]
rustiz posts get <id>
rustiz posts create --channel <id> [--channel <id> ...] --content "<p>Hi</p>" \
                    [--date <iso>] [--type draft|schedule|now] [--media <path|url> ...]
rustiz posts create --json post.json       # a full CreatePostDto, same as POST /public/v1/posts
rustiz posts update <id> [--content ...] [--date ...] [--publish] [--republish] [--json <file|->]
rustiz posts reschedule <id> --date <iso> [--publish] [--republish]
rustiz posts delete <id>                    # also deletes the other posts in its group

--type defaults to schedule, which needs --date. posts list defaults to the window from 7 days ago to 30 days ahead.

Media, analytics and slots

rustiz media list
rustiz media upload ./shot.png             # png/jpg/gif/webp/mp4/mov/webm/pdf
rustiz media upload-url https://cdn.example.com/clip.mp4
rustiz media delete <id>

rustiz analytics <channel-id> [--days 7]
rustiz notifications
rustiz find-slot <channel-id>

Using it from an agent without MCP

Any agent that can run shell commands can drive Rustiz through the CLI. The JSON output and exit codes are stable to parse:

id=$(rustiz channels list | jq -r '.[] | select(.identifier=="bluesky") | .id')
rustiz posts create --channel "$id" --content "<p>Shipped.</p>" --type now

REST API

Base URL: https://<your-host>/public/v1 (locally, http://localhost:5000/public/v1).

The paths and bodies follow Postiz’s public API, so most clients written for it only need a new base URL and key. The CLI and MCP server go through the same code, so behaviour is identical everywhere.

Authentication

Send the organization API key on every request, in either form:

Authorization: Bearer <api-key>
Authorization: <api-key>

Keys never go in URLs. Create and rotate them with rustiz org create and rustiz org rotate-key <org-id> (see CLI).

Rate limits

RouteDefault per hourSetting
POST /public/v1/posts90 per organizationAPI_LIMIT
/mcp and every other /public/v1 route10,000 per organizationREQUEST_LIMIT
POST /oauth/authorize, /oauth/register30 per client addressfixed
POST /oauth/token120 per client addressfixed

0 turns a limit off. Counts are kept per process.

Errors

Errors are JSON with an error message. Status codes: 400 bad request, 401 missing or invalid key, 404 not found, 409 the post is being published right now (retry later), 429 rate limited.

Posts

MethodPathNotes
GET/posts?startDate=&endDate=&customer=ISO 8601 window; returns { posts: [...] }
POST/postsCreate posts (body below). Uses API_LIMIT
GET/posts/{id}One post with its channel
PUT/posts/{id}Edit in place; omitted fields keep their value
PUT/posts/{id}/date{ date, publish?, republish? }
DELETE/posts/{id}Deletes the post and the rest of its group
DELETE/posts/group/{group}Deletes a whole group
GET/posts/{id}/missingRecent platform posts, for linking a post whose release id is missing
PUT/posts/{id}/release-idSet the platform post id manually
GET/find-slot/{channelId}Next free posting slot

Create a scheduled post with a reply:

curl -sS -X POST "$RUSTIZ_URL/public/v1/posts" \
  -H "Authorization: Bearer $RUSTIZ_API_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "type": "schedule",
    "date": "2030-01-01T09:00:00Z",
    "shortLink": false,
    "tags": [],
    "posts": [{
      "integration": { "id": "<channel id>" },
      "value": [
        { "content": "<p>We just shipped.</p>", "image": [] },
        { "content": "<p>Docs: https://example.com/docs</p>", "image": [] }
      ],
      "settings": {}
    }]
  }'

type is draft, schedule or now. value[0] is the post and the other entries are replies. inter (a number of days) repeats the post. The response lists { postId, integration, group } for each created post.

Published posts can’t be edited, because platforms keep the original. Use republish with a date to publish one again. A post in ERROR may already be live, so check the platform before you retry with publish. Deleting a post here does not remove it from the platform.

Channels (integrations)

MethodPathNotes
GET/integrationsConnected channels
DELETE/integrations/{id}Disconnect
GET/is-connectedChecks the API key
GET/providersEvery provider with its connection mode
GET/providers/statusReadiness: configured, missing env var names
POST/integrations/{provider}/connectStart a headless connection: { externalUrl?, customFields?, name?, description? }
GET/integrations/connect/{connectionId}Poll a pending connection
GET/social/{provider}Raw OAuth start URL (what Postiz returns)
GET/integration-settings/{id}The channel’s settings schema and rules
POST/integrations/{id}/timeReplace posting times
POST/integrations/{id}/mentionsLook up mentionable accounts

A connection either returns { connected: true, ... } or { connected: false, pending: true, connectionId, url, message }. Hand the url to a human, then poll. See the provider overview for each connection mode.

Media

MethodPathNotes
POST/uploadMultipart, field file (png, jpg, gif, webp, mp4, mov, webm, pdf)
POST/upload-from-url{ "url": "https://..." }
GET/media?page=Media library
GET/media/{id}/statusProcessing state
DELETE/media/{id}Removes it from the library

The size limit is MAX_UPLOAD_MB (default 1024). The upload rules are in Self-hosting §5.5.

Analytics and notifications

MethodPathNotes
GET/analytics/{channelId}?date=<days>Channel analytics
GET/analytics/post/{postId}?date=<days>Post analytics
GET/notificationsRecent notifications

Workspace

MethodPath
GET POST PUT/webhooks
DELETE/webhooks/{id}
POST/webhooks/send
GET POST/tags · PUT DELETE /tags/{id}
GET POST PUT/sets · DELETE /sets/{id}
GET POST/signatures · GET /signatures/default · PUT DELETE /signatures/{id}
GET/customers · PUT /customers/{id}

Debugging

MethodPath
GET/debug/posts/{id} (timeline of one post)
GET/debug/account
GET/debug/channels
GET/debug/activity

Outside /public/v1

PathPurpose
GET /healthReturns ok
/mcpMCP endpoint (MCP setup)
GET /integrations/social/{provider}OAuth callback that platforms redirect to
/.well-known/oauth-*, /oauth/*MCP OAuth
/uploads/*Local media, when UPLOAD_DIRECTORY is set

Organizations, API keys and agents

7. Organizations, API keys and agents

7.1 The organization model

There are no users, passwords or teams. An organization owns channels, posts, media, notifications and API keys. The API key identifies the organization on every request. A deployment can have several organizations; each key sees only its own rows.

rustiz init --name "Acme"             # name used only when it creates the first org
rustiz org create "Acme EU"           # prints organization id + API key
rustiz org list                       # id<TAB>name, one per line
rustiz org rotate-key <org-id>        # prints the new key

rustiz org ... prints plain text, not JSON. rotate-key also revokes every MCP OAuth access/refresh token issued to that organization; clients must re-authorize or be reconfigured with the new key. Channel credentials are not deleted by a key rotation.

rustiz init is idempotent: it keeps an existing JWT_SECRET, keeps the stored API key if it still matches an organization, and otherwise adopts the first existing organization rather than creating a second one and writes that organization’s key to config.env. It only errors if the adopted organization has no API key at all (then rustiz org rotate-key <id> is the fix).

7.2 Authentication

Send the key as either:

Authorization: Bearer <api-key>

or the raw value (what Postiz sends):

Authorization: <api-key>

/mcp/{key} does not exist in this port. Keys never travel in URLs or logs. The server hashes MCP OAuth tokens with SHA-256 and compares the hash; API keys are stored in the organization row.

7.3 MCP over stdio (local agent)

rustiz init prints the exact block. It looks like:

{
  "mcpServers": {
    "rustiz": {
      "command": "/usr/local/bin/rustiz",
      "args": ["mcp"]
    }
  }
}

The key is read from RUSTIZ_CONFIG/~/.rustiz/config.env; no secret appears in the snippet. rustiz mcp also starts the scheduler and, for a loopback HTTP FRONTEND_URL, the login callback listener. You can override the key:

rustiz mcp --api-key <key>

stdout carries the MCP protocol; diagnostics go to stderr. A service manager that captures stdout must not inject banners into it.

If the machine has no config, rustiz mcp fails with a message pointing at rustiz init rather than complaining about JWT_SECRET.

7.4 MCP over HTTP (remote agent)

Endpoint: ${FRONTEND_URL}/mcp.

Bearer API key (simplest):

{
  "mcpServers": {
    "rustiz": {
      "type": "http",
      "url": "https://social.example.com/mcp",
      "headers": {
        "Authorization": "Bearer <api-key>"
      }
    }
  }
}

The transport is stateless Streamable HTTP. Tool calls normally return JSON; subscriptions/listen uses an event stream. Host must be in the allowlist: FRONTEND_URL’s host (and host:port), localhost, 127.0.0.1, plus anything in MCP_ALLOWED_HOSTS.

OAuth 2.1 + PKCE (for clients that support it): omit the Authorization header. A request without credentials gets 401 with:

WWW-Authenticate: Bearer resource_metadata="https://social.example.com/.well-known/oauth-protected-resource/mcp"

Discovery and endpoints:

EndpointPurpose
GET /.well-known/oauth-protected-resource and .../mcpRFC 9728 resource metadata (resource, authorization_servers, bearer_methods_supported, scopes_supported: ["rustiz"]).
GET /.well-known/oauth-authorization-serverRFC 8414 authorization-server metadata.
GET/POST /oauth/authorizeConsent page. It asks for the organization API key; there is no user login.
POST /oauth/tokenAuthorization-code and refresh-token grants, token_endpoint_auth_method: none.
POST /oauth/registerRFC 7591 dynamic client registration (for older clients).

Details an operator may need:

  • Only response_type=code, S256 PKCE and a code challenge of at least 43 characters are accepted. resource must be ${FRONTEND_URL}/mcp or ${FRONTEND_URL}.
  • Client ID Metadata Documents are supported: a client id that is an HTTPS URL (or a plain-HTTP URL when ALLOW_PRIVATE_NETWORK=true) is fetched through the SSRF-safe client, max 64 KiB, and must contain a matching client_id and redirect_uris.
  • Registration accepts https URIs and http only for loopback hosts (localhost, 127.0.0.1, [::1]); at authorization the requested redirect_uri must match a registered one exactly, except that a loopback redirect may use any port (RFC 8252). javascript:, data:, file:, blob:, ftp:, ws:/wss: and fragments are rejected. At most 10 URIs, 2048 bytes each.
  • Authorization codes live 5 minutes and are single-use. Access tokens live 1 hour; refresh tokens live 30 days and are rotated on use.
  • The consent form is the only HTML the server renders besides the channel connect result page. It is protected by X-Frame-Options: DENY, a restrictive CSP, Referrer-Policy: no-referrer and Cache-Control: no-store.
  • Registering a client grants nothing until someone enters a valid API key on the consent page.
  • Revoke everything for an organization by rotating its API key.

7.5 Connecting a channel from an agent

REST and MCP share one code path.

rustiz channels list
rustiz providers --status
rustiz channels connect x --open
rustiz channels connect mastodon-custom --external-url https://mastodon.social
rustiz channels connect bluesky --fields ./bluesky.json
rustiz channels connect telegram
rustiz channels connect-status <connectionId>

--fields takes a JSON file or - for stdin. --timeout defaults to 300 seconds for the in-process OAuth wait.

MCP connectChannelTool arguments:

ArgumentUse
providerProvider identifier. Not needed when polling a connectionId.
connectionIdPoll a Telegram/Moltbook/Farcaster/OAuth handshake started earlier. Safe to repeat.
externalUrlSelf-hosted instance URL (required for mastodon-custom).
customFieldsCredential object for customFields providers. Telegram also accepts {"chatId":"-100..."}, Moltbook {"apiKey":"..."}, Farcaster {"signerUuid":"..."}.
nameMoltbook agent name to register.
descriptionMoltbook agent description.

If customFields is present and is a JSON object, it takes precedence over the OAuth flow for any provider. Only send it to providers whose integrationSchema lists customFields.

Special flows:

  • Telegram (telegramBot): with no customFields, the server returns connectionId, a word and a /connect <word> command. A human adds the bot to the target group/channel (ideally as admin), sends the command, then the agent polls with the same connectionId. With customFields.chatId it connects immediately.
  • Moltbook (moltbookClaim): the server registers an agent and returns claimUrl (and possibly a verification code). The human claims it, then the agent polls.
  • Farcaster (signer): the server creates a Neynar signer and returns approvalUrl/qrCode; the human approves in the Farcaster app, then the agent polls. With customFields.signerUuid of an already-approved signer it connects immediately.
  • Between-steps channels (is_between_steps: Facebook pages, Instagram accounts, LinkedIn companies, YouTube channels, Google Business locations, Tumblr blogs): the OAuth login creates a channel with inBetweenSteps=true. List the choices with triggerTool and finish with channelSetupTool:
{
  "integrationId": "<id>",
  "methodName": "pages",
  "dataSchema": []
}
{
  "integrationId": "<id>",
  "data": { "page": "<page-id>" }
}

triggerTool only allows function names declared in the provider’s integrationSchema (functions array); it refreshes an expired token once and disconnects on a hard auth error. channelSetupTool rejects a channel that is already set up and reports That page is already connected as another channel on a uniqueness conflict.

OAuth providers need a human to open the URL. MCP clients that declare URL elicitation receive the link through the multi-round-trip flow; other clients get {connected:false, pending:true, connectionId, url, message} and should poll with connectionId or re-check integrationList.

7.6 Posting workflow

Typical tool order:

providersStatusTool
integrationList
integrationSchema { platform }
connectChannelTool (if needed)
validatePostTool
schedulePostTool
postStatusTool
  • validatePostTool checks channel state (disabled, refresh needed, between steps), provider validate_settings, character count (HTML stripped, counted in characters), thread support and check_validity media rules. It does not create anything.
  • schedulePostTool accepts socialPost[] with integrationId, date (UTC), type (draft/schedule/now), optional shortLink, settings as [{key,value}] and postsAndComments ([0] is the post, the rest are replies). attachments may be a URL string or an object with path, alt, thumbnail, thumbnailTimestamp.
  • Resends are safe: an identical call within 10 minutes, or the same idempotencyKey within 24 hours, returns the first result. Reusing a key with different arguments is rejected. An in-flight or abandoned claim tells you to check postsListTool before retrying with a new key.
  • With the MCP Tasks extension, type:"now" returns a durable task handle; otherwise poll postStatusTool. Task state is derived from posts and survives restarts.
  • subscriptions/listen on rustiz://posts/<postId> or rustiz://groups/<groupId> sends a hint on state change; it does not carry content or errors.

Media storage and ffmpeg

5. Media storage and ffmpeg

5.1 Local storage

STORAGE_PROVIDER=local
UPLOAD_DIRECTORY=/var/lib/rustiz/uploads
FRONTEND_URL=https://social.example.com
  • Files written by uploads and generated media use <yyyy>/<mm>/<dd>/<32-char-id>.<ext>.
  • The public URL is ${FRONTEND_URL}/uploads/<key>; the same process serves the directory with tower-http’s ServeDir.
  • It must be a persistent volume and must be writable by the service user.
  • With more than one API replica, use a shared volume or object storage. A local directory on one replica is invisible to the others.
  • A reverse proxy must not strip or rewrite /uploads.

5.2 S3 / R2

STORAGE_PROVIDER=cloudflare
CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_ACCESS_KEY=...
CLOUDFLARE_SECRET_ACCESS_KEY=...
CLOUDFLARE_BUCKETNAME=rustiz-media
CLOUDFLARE_BUCKET_URL=https://media.example.com
CLOUDFLARE_REGION=auto

Or:

STORAGE_PROVIDER=s3
AWS_BUCKETNAME=rustiz-media
AWS_REGION=eu-west-1
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_BUCKET_URL=https://media.example.com
# AWS_ENDPOINT=http://minio:9000 for MinIO

The bucket must be publicly readable over HTTPS (or fronted by a public CDN). Some providers — TikTok and TikTok Business in particular — fetch media by URL (pull_from_url) rather than accepting an upload, so a private or loopback /uploads URL cannot work for them. Public object storage is the robust choice for those providers. The media host must also be allowed in the platform’s developer app where the platform asks for verified domains.

Object keys differ slightly: with object storage, API uploads use <32-char-id>.<ext> while generated media (converted JPEGs, thumbnails) uses <yyyy>/<mm>/<dd>/<id>.<ext>. Both are addressed through CLOUDFLARE_BUCKET_URL / AWS_BUCKET_URL.

R2 lifecycle rules are bucket-wide (optionally prefix-filtered) and cannot ask SQLite which objects a post still references. Therefore:

  1. Always add a rule to abort incomplete multipart uploads, e.g. after 1 day. Rustiz streams uploads through object_store; a crashed process can otherwise leave billable parts behind.
  2. If you need a retention policy, do not put a blanket 30-day expiration on the media prefix. Scheduled and published posts older than 30 days still reference those objects, and deleting them breaks previews, reposts and platform pulls. A 30-day rule is appropriate only for a dedicated scratch or trash prefix.
  3. A safe pattern is: run a reconciliation job that copies objects no longer referenced by Media/Post rows into trash/<yyyy>/<mm>/, then apply a 30-day expiration to trash/ and let R2 delete it. Keep live media in the normal prefix with no expiry.
  4. If a compliance policy requires deleting all media after 30 days, export or archive it first and accept that old posts can no longer be reposted or previewed.

5.4 ffmpeg and video thumbnails

Video thumbnails are generated by shelling out to ffmpeg:

ffmpeg -y -ss <seconds> -i <temp-input> -frames:v 1 -q:v 2 <temp-output.jpg>

The engine first runs ffmpeg -version. If the binary is missing, it logs ffmpeg not available; skipping video thumbnail and marks the media ready without a thumbnail; publishing is not failed. FFMPEG_BINARY can point at an absolute path.

ffmpeg is not needed for PNG→JPEG conversion: providers with convert_to_jpeg (TikTok and TikTok Business) are handled by the image crate at JPEG quality 100. That conversion does need UPLOAD_DIRECTORY or object storage, because the converted bytes are written back through the same storage layer.

The stock Docker image is distroless and has no ffmpeg. Two ways to add it:

Static binary (recommended; keeps distroless):

FROM rust:1-slim AS build
WORKDIR /src
COPY . .
RUN cargo build --release --locked -p rustiz

FROM gcr.io/distroless/cc-debian12
COPY --from=mwader/static-ffmpeg:9.0.2-1 /ffmpeg /usr/local/bin/ffmpeg
COPY --from=build /src/target/release/rustiz /rustiz
ENV DATABASE_URL=sqlite:///data/rustiz.db UPLOAD_DIRECTORY=/data/uploads PORT=5000
VOLUME /data
EXPOSE 5000
ENTRYPOINT ["/rustiz"]
CMD ["serve"]

Pin the mwader/static-ffmpeg tag; the latest image is large (hundreds of MiB). The static binary has no external library dependencies and works on the distroless glibc base.

Debian runtime:

FROM debian:bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
 && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/rustiz /usr/local/bin/rustiz
# ... same ENV/ENTRYPOINT as above

This is simpler but larger and gives the container a shell and package manager. Whichever path you choose, keep /data writable and persistent.

5.5 Upload validation rules

POST /public/v1/upload (multipart field file) and POST /public/v1/upload-from-url accept only:

png jpg jpeg gif webp mp4 mov webm m4v pdf
  • The extension comes from the filename, or from the URL path for upload-from-url; a URL without a recognised extension is rejected.
  • The body limit is MAX_UPLOAD_MB (default 1024 MiB). The reverse proxy and Cloudflare have their own, often lower, limits.
  • Remote uploads are fetched through the SSRF-safe client; only http/https is allowed. The guard blocks loopback, private, link-local and metadata addresses unless ALLOW_PRIVATE_NETWORK=true.
  • Local media reads are confined to UPLOAD_DIRECTORY with lexical path normalisation; ../ and sibling prefixes such as uploads-evil are rejected.
  • Remote reads have a 120-second timeout and one retry on timeout. Range requests are required by some providers; S3/R2 serve them natively, and local files are read with seek.
  • Deleting media is a soft delete: the database row is marked deleted but the stored file is kept (a published post may still reference it). Reclaiming storage is an operator task; see §5.3.

Reverse proxy, TLS and OAuth callbacks

6. Reverse proxy and TLS

The server speaks plain HTTP and has no TLS listener. Terminate TLS at Caddy, Nginx, Traefik or Cloudflare. FRONTEND_URL must be the public HTTPS origin, with no path prefix: the app assumes it owns /, /public/v1, /mcp, /oauth, /.well-known and /uploads.

6.1 Caddy

social.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:5000 {
        flush_interval -1
        header_up X-Forwarded-Proto {scheme}
    }
}

Caddy obtains and renews certificates automatically and appends the client address to X-Forwarded-For, so set TRUSTED_PROXY_HOPS=1 when Rustiz is reachable only through Caddy.

flush_interval -1 disables response buffering, which matters for subscriptions/listen and long MCP responses.

6.2 Nginx

server {
    listen 443 ssl http2;
    server_name social.example.com;

    ssl_certificate     /etc/letsencrypt/live/social.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/social.example.com/privkey.pem;

    client_max_body_size 1024m;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

$proxy_add_x_forwarded_for appends the peer address; one Nginx hop means TRUSTED_PROXY_HOPS=1. If Nginx is itself behind Cloudflare, see §6.3.

6.3 Cloudflare

  • Use SSL/TLS → Full (strict) with a Cloudflare Origin Certificate or a valid public certificate on the origin.
  • Cloudflare’s proxy has a 100-second origin response timeout on most plans. Normal API calls and most MCP tool calls fit, but a long-lived subscriptions/listen stream may be cut. Options: give MCP a DNS-only hostname, keep the stream short and let the client reconnect, or use an Enterprise plan with a longer timeout.
  • Cloudflare’s request-body limit depends on plan (commonly 100 MB). Set MAX_UPLOAD_MB and the origin proxy limit to match; otherwise large video uploads fail at the edge before reaching Rustiz.
  • Do not enable “Under Attack” or a challenge on /mcp; agents cannot solve it. Use a WAF skip rule for /mcp, /oauth/* and /.well-known/* if needed.
  • With Cloudflare → Nginx → Rustiz there are two appended XFF entries. Set TRUSTED_PROXY_HOPS=2 and firewall the origin so it is reachable only from Cloudflare. With Cloudflare connecting directly to Rustiz, 1 is correct. The limiter does not verify the peer address; the hop count is only safe when the origin cannot be reached directly.

6.4 OAuth callback URLs

Every provider’s redirect URI is built as:

{FRONTEND_URL}/integrations/social/{identifier}

FRONTEND_URL is trimmed of trailing slashes, so register the URI without a trailing slash unless the provider’s table explicitly shows one. The exact paths and exception cases are in the master provider table; the important ones:

  • tiktok-business ends with / (.../tiktok-business/); the platform requires it and the token exchange sends the same value.
  • mastodon-custom reuses /integrations/social/mastodon.
  • x uses /integrations/social/x, unless X_URL overrides the base.
  • threads, slack, vk, tiktok, tiktok-business and instagram-standalone wrap the URI as https://redirectmeto.com/<FRONTEND_URL>/... when FRONTEND_URL does not contain the substring https. In production this should never happen; use HTTPS.
  • facebook, instagram, pinterest, reddit, linkedin, youtube, gmb, tumblr, twitch, discord, dribbble, whop and mewe are registered directly; several of them refuse plain http:// redirects even on localhost.

The callback route is GET /integrations/social/{provider}. It reads code and state from the query, consumes the one-time state row, exchanges the code and stores the channel (access tokens in plain text; customFields credentials and custom-instance client data encrypted). OAuth state lives for one hour; Telegram/Moltbook connection ids also one hour; Farcaster signers 30 minutes. A second use of the same state fails with This connection link expired, please start again.

The server does not render a dashboard. The callback renders a minimal HTML “Channel connected” / “Could not connect the channel” page.

6.5 Local OAuth without a public server

rustiz channels connect <provider> and rustiz mcp can receive the platform callback themselves, but only when FRONTEND_URL is plain HTTP on localhost or 127.0.0.1. They bind 127.0.0.1:<port> and wait. If the port is already in use by another rustiz using the same database, the command warns and waits for that process to finish the login.

If FRONTEND_URL is a public HTTPS URL or a non-loopback host, the CLI cannot receive the callback; it prints the login URL and tells you the server must handle it.


Operations: health, backups, upgrades

8. Operations

8.1 Health and readiness

curl -fsS http://127.0.0.1:5000/health        # -> ok
curl -fsS -H "Authorization: Bearer $KEY" \
  https://social.example.com/public/v1/providers/status | jq

providers/status returns, per provider:

{
  "identifier": "x",
  "name": "X",
  "configured": true,
  "missing": [],
  "connection": "oauth"
}

Only variable names are reported, never values. A provider with configured:false is refused before an OAuth URL is generated; the error names the missing variables and the redirect URI to register.

8.2 Logs

Logs go to stderr as plain-text tracing lines (the default subscriber format, with fields appended). Useful filters:

RUST_LOG=info,rustiz=debug,sqlx=warn,tower_http=info rustiz serve

Do not send MCP stdio logs to stdout. If you wrap rustiz mcp in a supervisor, route stderr to a file and leave stdout for the protocol.

8.3 Backups

Back up both the database and JWT_SECRET.

# Online, consistent backup (WAL aware).
sqlite3 /var/lib/rustiz/rustiz.db \
  ".backup '/var/backups/rustiz-$(date +%F).db'"

# Or stop the service and copy the database plus any -wal/-shm files.
systemctl stop rustiz
cp -a /var/lib/rustiz/rustiz.db* /var/backups/
systemctl start rustiz

Also back up config.env (or at least JWT_SECRET and RUSTIZ_API_KEY) and the upload directory or bucket. A database restored without the original secret can no longer decrypt customFields credentials or custom-instance client information, so those channels must be reconnected. Provider access tokens remain readable because they are stored in plain text in the database — which also means a database backup is a bearer-credential backup: encrypt it and restrict access to it.

Test restores on a separate copy. Do not run two servers against the same SQLite file over NFS or a network filesystem.

8.4 Upgrades

  1. Back up.
  2. Build/copy the new binary.
  3. rustiz migrate (or just start; migrations run automatically).
  4. Restart serve and worker; graceful shutdown closes MCP subscription streams.
  5. Check /health and providers --status.

Schema migrations are applied in order and are not automatically reversible. Read the release notes before a downgrade.

8.5 Scaling

  • One SQLite file supports one writer at a time. Scale vertically first: WORKER_CONCURRENCY, MCP_SUBSCRIPTIONS_MAX_STREAMS, REQUEST_LIMIT.
  • Multiple serve replicas are possible only with a shared database; SQLite on a shared local volume is not safe across hosts. Object storage is required for media if replicas do not share a volume.
  • Rate limits are per process, so N replicas mean N× the configured budget.
  • Use one worker unless you have measured lease contention; the job table uses five-minute leases and heartbeats and can handle several workers, but SQLite write contention is the practical limit.
  • Long-lived MCP streams are cheapest on a single instance; a proxy with buffering disabled is mandatory.

8.6 Security checklist

  • FRONTEND_URL is HTTPS and matches the registered redirect URIs.
  • JWT_SECRET is random and stable, config.env is 0600, and both are backed up. It protects custom credentials, not OAuth tokens.
  • The SQLite file and its backups are encrypted at rest: provider access tokens (including OAuth tokens and some API keys) are stored in plain text in the database.
  • Rustiz binds 127.0.0.1 (or a private interface) behind a TLS proxy.
  • ALLOW_PRIVATE_NETWORK is unset unless the deployment is single-tenant and the network is trusted.
  • API keys are distributed over a secret manager, not chat or URLs.
  • TRUSTED_PROXY_HOPS matches the real proxy chain and the origin is firewalled to those proxies.
  • The /data volume and UPLOAD_DIRECTORY are writable only by the service user.
  • The object bucket is public only for media, and its write credentials are not exposed to clients.
  • Backups are encrypted and restore-tested.
  • RUST_LOG does not log request bodies containing credentials (the port redacts bodies stored in Errors, but proxy logs are your responsibility).

Troubleshooting

9. Troubleshooting

SymptomLikely cause / fix
JWT_SECRET must be setserve/worker/mcp started without the config file. Set RUSTIZ_CONFIG or run rustiz init. The secret must be the original one.
no API key found: run rustiz init ...rustiz mcp has no key. Run rustiz init, or pass --api-key/RUSTIZ_API_KEY.
invalid API key (401)Key rotated or from another organization. rustiz org list, then rustiz org rotate-key.
... is not configured on this server: set X. Register this redirect URI ...Missing provider env vars. Set them and restart; register the exact URI shown.
This connection link expired, please start againOAuth state older than 1 hour or already consumed. Restart the connect flow.
timed out after 300s waiting for the login to finishHuman did not finish the platform login, or the callback never reached the server. Check FRONTEND_URL, proxy routing and the platform’s redirect URI.
Platform says redirect_uri_mismatchCompare scheme, host, port and trailing slash exactly. tiktok-business needs a trailing slash; FRONTEND_URL must not have one. HTTPS-only platforms reject http://.
Login page has an empty client_idProvider credentials missing or wrong names (e.g. DRIBBBLE_* not DRIBBLE_*, KICK_SECRET not KICK_CLIENT_SECRET). rustiz providers --status shows names.
429 / ThrottlerExceptionAPI_LIMIT or REQUEST_LIMIT reached. Raise them, wait for the window, or set 0 to disable. OAuth endpoints have fixed per-IP limits.
413 Payload Too LargeMAX_UPLOAD_MB or the proxy body limit. Raise both, and check Cloudflare’s plan limit.
/uploads/... 404UPLOAD_DIRECTORY unset or not the directory containing the file; proxy strips /uploads; wrong FRONTEND_URL.
Video has no thumbnail, but post succeedsffmpeg missing. Install it or use a Docker image with FFMPEG_BINARY set. This is a clean degradation, not a failed post.
ffmpeg exited with ...Corrupt/unsupported video or a non-executable FFMPEG_BINARY. Check the path and file.
MCP returns 401 despite a keyWrong header; use Authorization: Bearer <key>. OAuth clients must complete discovery/consent; check WWW-Authenticate.
MCP 400/403 about HostAdd the exact host:port to MCP_ALLOWED_HOSTS; ensure the proxy preserves Host.
subscriptions/listen disconnectsProxy buffering/timeout (disable buffering, raise read timeout), Cloudflare’s 100s limit, or a slow client hit MCP_SUBSCRIPTIONS_SEND_TIMEOUT_MS.
Telegram 409 Conflict: terminated by other getUpdatesTwo processes use the same bot token. Use one token per deployment; stop the duplicate.
database is lockedAnother process holds the writer beyond the 10s busy timeout. Reduce worker concurrency, avoid long transactions, keep the DB on local disk.
missing required environment variable X when a feature is first usedA required storage/email value is absent. The message names the variable; check §4.
R2 objects 403 / broken imagesCLOUDFLARE_BUCKET_URL is wrong, the bucket is private, or the custom domain is not connected. The app does not sign URLs.
OAuth login succeeds but channel is missingCallback handled by a different database/instance, or the state was consumed there. Use one FRONTEND_URL and one database per deployment.
Channel setup is not finishedis_between_steps channel. Use triggerTool + channelSetupTool (Facebook page, Instagram account, LinkedIn company, YouTube channel, GMB location, Tumblr blog).

For platform-specific registration steps, see the provider pages in ./providers/ and the setup tiers in Documentation overview.

Bluesky (bluesky)

Bluesky is the reference client for the AT Protocol. Rustiz speaks XRPC over plain HTTP, authenticates with a handle + app password (there is no OAuth app and no redirect URI), and publishes app.bsky.feed.post records to the account’s PDS. Video is uploaded to Bluesky’s separate video service and polled asynchronously.

  • Rust source: crates/rustiz-providers/src/bluesky.rs
  • Upstream port: bluesky.provider.ts
  • Editor: Normal
  • Connection mode: customFields
  • No developer app, no OAuth, no app review.

Overview

PropertyValue
Identifierbluesky
Display nameBluesky
Connection modecustomFields (service URL, identifier, app password)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; replies use app.bsky.feed.post with a reply ref
Pending / async publishingYes. supports_pending = true; video is started in post_pending() and polled before createRecord
AnalyticsNo
Post analyticsNo
MentionsYes. supports_mentions = true; app.bsky.actor.searchActors
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job6
Character limit (max_length)300 characters, hard-coded
Tool tip“We don’t currently support two-factor authentication. If it’s enabled on Bluesky, you’ll need to disable it.”
OAuth scopesNone. info.scopes = ["write:statuses", "profile", "write:media"] is cosmetic; no OAuth flow uses it.

Server Environment Variables

required_env() returns nothing:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
BLUESKY_VIDEO_BASE_URLNohttps://video.bsky.appOverrides the video service origin. Trailing slash trimmed. Read through Config::base_url("BLUESKY_VIDEO", …).
DISABLE_SSRF_PROTECTIONNounsetWhen true, skips the public-HTTPS check on the service URL. Needed for a PDS on a private network or http://localhost. Global switch; use only on a trusted network.
JWT_SECRETYes in practiceemptyEncrypts the stored customFields blob (service, identifier, password) as customInstanceDetails

There is no BLUESKY_BASE_URL. The PDS origin is a per-channel customFields.service value, not a server env var. BLUESKY_VIDEO_BASE_URL only changes the video upload host; it does not change the PDS.

rustiz providers --status reports bluesky as configured even before any account is connected, because the credentials arrive per channel.

Step-by-Step Developer App Setup

There is no Bluesky developer portal application to create. The “app” is an app password created by the account owner.

1. Prepare the account

  1. Sign in at https://bsky.app.
  2. Open Settings → Privacy and security → App Passwords (the direct URL is https://bsky.app/settings/app-passwords).
  3. Choose Add App Password, name it Rustiz, and copy the generated password. It looks like xxxx-xxxx-xxxx-xxxx.
  4. Keep the account’s normal password out of Rustiz. An app password can be revoked individually without changing the account password.

Bluesky app passwords are intended exactly for this: third-party clients that need to create records but should not be able to change the account password or email. They still act as the account for posting, deleting and reading.

2. Know the service URL

Account typeservice value
Normal Bluesky-hosted accounthttps://bsky.social
Custom-domain handle on bsky.socialhttps://bsky.social (the handle is the identifier)
Self-hosted PDSThe PDS origin, e.g. https://pds.example.com
Local PDS for testinghttp://localhost:2583 with DISABLE_SSRF_PROTECTION=true

The handle alone does not identify the PDS: use the PDS origin. service is trimmed of a trailing slash. It must be https unless SSRF protection is disabled.

3. Redirect URI

There is none. Bluesky’s password/session flow has no browser redirect, no redirect_uri, no client id and no client secret. connectChannelTool with customFields completes synchronously.

4. Scopes

Bluesky app passwords have no scope selection. The provider’s info.scopes is a Postiz compatibility field only; check_scopes() is never called. The app password grants whatever the account can do through com.atproto.server.createSession, except account-administration endpoints that AT Protocol blocks for app passwords.

5. Sandbox / testing vs production

  • There is no Bluesky sandbox and no app review. The same app password works against production immediately.
  • Use a secondary Bluesky account, or a self-hosted PDS, for tests. A test post is public on the account’s profile.
  • Video uploads require a verified email address on the Bluesky account and consume the account’s daily video quota. A brand-new test account may not be allowed to upload video.
  • If the account has 2FA enabled and createSession answers AuthFactorTokenRequired, the Rust port has no authFactorToken handling and reports invalid credentials. The provider’s tool tip says to disable 2FA; the safer modern alternative is to rely on an app password, but the code does not implement the 2FA challenge.
  • Revoking the app password immediately invalidates the stored session; there is no refresh path (see below).

Connecting a Channel (CLI & MCP)

customFields keys

KeyTypeDefaultValidation regex (exact string from custom_fields())Notes
servicetexthttps://bsky.socialSee the exact regex belowPDS origin. Despite the optional scheme and http in the regex, authenticate() requires a public HTTPS URL unless SSRF protection is off. A bare bsky.social passes the regex but fails the HTTPS check.
identifiertext—/^.+$/Handle, DID or email. A single leading @ is stripped by login_identifier().
passwordpassword—/^.{3,}$/App password (xxxx-xxxx-xxxx-xxxx), not the account password.

Exact service validation string:

/^(https?:\/\/)?((([a-zA-Z0-9\-_]{1,256}\.[a-zA-Z]{2,6})|(([0-9]{1,3}\.){3}[0-9]{1,3}))(:[0-9]{1,5})?)(\/[^\s]*)?$/

CLI

Create a JSON file:

{
  "service": "https://bsky.social",
  "identifier": "alice.bsky.social",
  "password": "abcd-efgh-ijkl-mnop"
}
rustiz channels connect bluesky --fields ./bluesky.json

# or pipe it
printf '%s' '{"service":"https://bsky.social","identifier":"alice.bsky.social","password":"abcd-efgh-ijkl-mnop"}' \
  | rustiz channels connect bluesky --fields -

A successful credential connection is immediate:

{ "connected": true, "pending": false, "name": "Alice" }

No browser opens and no callback is served. --open / --timeout do nothing useful for this provider.

MCP connectChannelTool

{
  "provider": "bluesky",
  "customFields": {
    "service": "https://bsky.social",
    "identifier": "alice.bsky.social",
    "password": "abcd-efgh-ijkl-mnop"
  }
}

integrationSchema reports:

{
  "type": "customFields",
  "headless": true,
  "requiresHuman": false
}

There is no follow-up. Call integrationList for the channel id.

Important: do not omit customFields

If connectChannelTool is called for bluesky without customFields, connect.rs falls through to the OAuth path. Bluesky::generate_auth_url() returns the random state string as both url and state (a legacy of the upstream base class), so the agent receives a “login URL” that is not a URL and no channel is created. Always send the credential object.

Likewise, an empty object {} is still an object: connect_custom() encodes it and authenticate() fails with Invalid credentials. Send all three keys.

Provider functions (triggerTool)

None. functions() is empty. integrationSchema.functions is [].

Post Settings & Media Validation

settings_schema()

Bluesky does not override the trait default:

{ "type": "object", "properties": {}, "additionalProperties": true }

There are no provider-specific settings. __type is added by the engine. The post text and media are the only inputs; there are no reply-gate, language, label or quote settings.

validate_settings()

Not overridden: every settings object is accepted.

check_validity()

The provider checks exactly two rules, per post (each Vec<ValidityMedia> is one entry in the thread):

#![allow(unused)]
fn main() {
if posts.iter().any(|post| {
    post.iter().any(|media| media.path.contains("mp4")) && post.len() > 1
}) {
    return Ok(Some("You can only upload one video per post.".into()));
}
if posts.iter().any(|post| post.len() > 4) {
    return Ok(Some("There can be maximum 4 pictures in a post.".into()));
}
}
  • One .mp4 may be the only media in a post.
  • An .mp4 plus any other media is rejected.
  • More than four media items is rejected, even if some are videos.
  • The contains("mp4") check is case-sensitive; VIDEO.MP4 bypasses the rule, while has_extension() later treats it as a video. This is a port bug: a .MP4 plus an image can reach the upload path and the video branch silently ignores the image.
  • There is no file-size check, no aspect-ratio check, no alt-length check and no video-duration check.

Media requirements

MediaBluesky limitRustiz behavior
ImagesUp to 4 per post, 2 MB each (current Bluesky limit; older docs say 1 MB)Each image is resized down until the encoded file is at most about 976 KiB, then uploaded with com.atproto.repo.uploadBlob. Aspect ratio is preserved and stored in the embed.
VideoOne MP4 per post, up to 300 MB / 10 minutes (current limits; Rustiz does not enforce either)Uploaded to the video service, polled every 30 s for up to 18 attempts (~9 minutes).
Alt textPer imagePreserved in app.bsky.embed.images[].alt; optional.
MixedNot allowedcheck_validity() rejects an .mp4 with any other media (except the case-sensitivity hole above).
GIFDisplayed as a static imageRust decodes and re-encodes the first frame.

Images are uploaded with Content-Type: application/octet-stream, not their real image MIME type. AT Protocol’s uploadBlob records the request content type on the blob; a PDS may therefore store the blob as application/octet-stream and clients may not render it. If images appear as downloads instead of pictures, this is the first thing to fix.

Facets

The provider reproduces @atproto/api’s RichText.detectFacets:

  • Mentions: (^|\s|\()@handle where the handle is a valid domain; resolved with com.atproto.identity.resolveHandle and stored as a app.bsky.richtext.facet#mention with a did.
  • Links: http(s)://… or a bare domain with a valid TLD; trailing punctuation and unmatched ) are trimmed; stored as app.bsky.richtext.facet#link.
  • Hashtags: # or full-width # at the start of a word; tag trimmed of punctuation; max 64 UTF-16 code units; stored as app.bsky.richtext.facet#tag.

Offsets are UTF-8 byte offsets, as AT Protocol requires. Facets are omitted when the list is empty.

No link preview cards. Rustiz never creates an app.bsky.embed.external card. A URL in the text is a clickable facet, not a card with title/description/image.

Plugs

run_plug() implements two methods (both are @Plug-style automations, run after the post is live):

MethodFieldsBehavior
autoRepostPostlikesAmountReads the post thread, compares likeCount >= likesAmount, waits 2 s, then creates an app.bsky.feed.repost record.
autoPlugPostlikesAmount, postSame threshold check, then creates a reply to the post whose text is the post field after HTML stripping (Editor::Normal). The reply uses the post as both root and parent.

Fields arrive as JSON; plug_text() accepts strings, numbers and booleans and stringifies them. An empty or non-numeric likesAmount becomes 0, so the plug fires on the next run regardless of likes. run_post_plug() is not overridden and returns Unsupported.

Implementation Notes & Gotchas

  • There is no OAuth. generate_auth_url() is a legacy stub that returns the state string as the URL. The only working connect path is customFields; without it the agent gets a bogus URL (see above).
  • Login on every publish. The stored accessJwt is not reused for publishing: get_agent() calls com.atproto.server.createSession with the stored handle/app password for every post, comment, mention search and plug. This is simple but burns the createSession rate limit (30 per 5 minutes, 300 per day per account) and means a revoked app password fails at publish time even if the channel was connected successfully.
  • No refresh. refresh_token() returns an empty token and expires_in: 0. The stored refreshJwt is never used. A 4xx login (other than 429) is classified as refresh_token, so the channel is marked for reconnect. There is no com.atproto.server.refreshSession implementation.
  • Two-phase duplicate protection. Bluesky uses a prepare/commit handshake in pending_data:
    1. post_pending() starts the video job (if any) and stores {jobId, message, media}.
    2. check_post_status() polls the video job. When ready, finalize_post() is called.
    3. The first finalize_post() only sets attempting=true, confirmed=false and returns Pending.
    4. The next check_post_status() sees attempting=true and sets confirmed=true; it returns Ready.
    5. The second finalize_post() performs the upload/createRecord and returns Completed. If a create attempt is uncertain, the next check_post_status() sees attempting && confirmed and refuses with “Bluesky may have already published this post…”. This is deliberate: createRecord is the one irreversible step and is called with execute (no retry, no 401 reclassification).
  • Preparation failures are disarmed. If login or blob upload fails before createRecord, prep_failure() clears attempting/confirmed and increments prepFailures; after 4 failures it returns “Could not prepare the post for Bluesky, nothing was published”. This lets the workflow retry without a false duplicate warning.
  • Video polling. start_video_upload() calls com.atproto.server.getServiceAuth with aud=did:web:<PDS host>, lxm=com.atproto.repo.uploadBlob, exp=now+30m, then streams the MP4 to app.bsky.video.uploadVideo?did=…&name=…. getJobStatus is public (no auth). The job must reach blob != null; JOB_STATE_FAILED is a hard error. The blocking upload_video() waits 18 × 30 s (~9 minutes).
  • Image resizing is aggressive. The loop shrinks by 10% until the encoded file is about 976 KiB, not Bluesky’s current 2 MB. A 12 MP photo may be reduced more than necessary. The re-encode uses image::guess_format (JPEG is the fallback when the format cannot be guessed).
  • GIFs become static. image::load_from_memory decodes the first frame; the re-encode is a still image. Bluesky displays GIFs as static images anyway, but do not expect animation.
  • Only .mp4 is recognized as video. has_extension(path, "mp4") is the only video test. A .mov, .webm or .m4v is treated as an image: it is decoded with the image crate and uploaded as an image blob, which will fail or produce a non-video embed. Transcode non-MP4 video to MP4 before posting.
  • No size pre-check. check_validity() only counts items. A 20 MB image is accepted by validation, resized locally, and then uploaded; a 400 MB video is accepted by validation and rejected by the video service or the engine’s upload limits. MAX_UPLOAD_MB and the reverse proxy limit are the practical caps.
  • Facet resolution can fail softly. If resolveHandle fails, the mention facet is still emitted with an empty did; Bluesky may reject the record or ignore the facet. There is no per-facet error surfaced to the user.
  • 300 characters is not the whole limit. Bluesky’s lexicon also has a 3,000-byte ceiling per post. The engine counts chars() on the stripped text, not graphemes and not bytes. This is stricter than Bluesky for emoji sequences (a family emoji counts as several chars) but does not protect against 300 four-byte characters exceeding 3,000 bytes.
  • Rate limits. Current Bluesky documented limits: 3,000 requests per 5 minutes per IP; createSession 30 per 5 minutes and 300 per day per account; record writes cost points (create 3, update 2, delete 1) with 5,000 points per hour and 35,000 per day. Video has separate daily quotas. Because Rustiz logs in for every post, a high-volume queue can exhaust createSession before it exhausts record writes.
  • max_concurrent_job = 6 is the provider’s own concurrency cap. It does not reserve rate-limit budget.
  • Video requires a verified email and a profile. Bluesky rejects video uploads from accounts without a verified email (and, in some clients, a display name/avatar). Rust surfaces the video job’s failure as “Could not upload video, job failed”.
  • Release URL is https://bsky.app/profile/{did}/post/{rkey}, built from the at:// URI returned by createRecord. The stored post_id is the full AT URI, not just the rkey.
  • Threads. comment() calls app.bsky.feed.getPostThread?uri=…&depth=0 to get the parent cid and the root ref. For a top-level parent, it uses the parent as both root and parent. For a reply, it reads record.reply.root. The reply’s text and media go through the same facet and embed logic.
  • Mentions. mention() calls app.bsky.actor.searchActors?q=… and maps handle to id, displayName to label, avatar to image. The engine’s mention_format() prefixes @.
  • No analytics. Bluesky has no analytics() / post_analytics() implementation in this port, even though the public AppView exposes counts.
  • SSRF check. is_safe_public_https_url() requires https, rejects localhost, resolves the host and rejects private/blocked IPs. It runs only during authenticate(), not on every publish; a PDS that resolves publicly at connect time and privately later is not rechecked.
  • Custom PDS caveat. service_auth() derives the video audience from the PDS host as did:web:<host>. A self-hosted PDS that is not addressed by a did:web matching its hostname may fail video service auth; text and image posts still work.

References

Dev.to (devto)

Dev.to (Forem) is an article provider: the post body is Markdown and the publish call creates an article with published: true. Rustiz talks to the Forem API with a personal API key. There is no developer app, no OAuth exchange, no scope model and no app review.

  • Rust source: crates/rustiz-providers/src/devto.rs
  • Upstream port: dev.to.provider.ts
  • Editor: Markdown
  • Connection mode: customFields

Overview

PropertyValue
Identifierdevto
Display nameDev.to
Connection modecustomFields (API key collected by the connect form / MCP)
Editormarkdown
Channel kindsocial (article content)
Comments / threadsNo. supports_comments = false; only the first PostDetails is published
Pending / async publishingNo. supports_pending = false; post() returns PostStatus::Completed
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)100000 characters for the article body
API versionForem API v1 endpoints (/api/...), api-key header

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. There is nothing to register, so rustiz providers --status reports devto as configured even before any user has supplied an API key.

VariableRequiredDefaultPurpose
DEVTO_BASE_URLNohttps://dev.toOverrides the API origin. The value is trimmed of a trailing slash. Useful for a Forem instance or a mock server. Example: https://dev.to.
RESTRICT_UPLOAD_DOMAINSNounsetIf set, main_image.path must contain this substring. This is a deployment policy, not a Dev.to requirement.

required_env() purposefully does not include DEVTO_BASE_URL; it is only an optional override read through Config::base_url("DEVTO", ...).

Step-by-Step Developer App Setup

There is no developer portal application to create.

  1. Sign in to the Dev.to account that should publish the articles.

  2. Open Settings → Extensions: https://dev.to/settings/extensions.

  3. Click Generate API Key (the UI may call it Generate New Key).

  4. Copy the key. Treat it as a password: it can create, update and unpublish articles on that account. There are no scopes to select.

  5. Optionally verify it before connecting:

    curl -s https://dev.to/api/users/me \
      -H "api-key: $DEVTO_API_KEY"
    

    The response contains id, name, username and profile_image, which are exactly the fields Rustiz stores during authenticate.

Products and permissions

  • No products or APIs must be enabled.
  • No OAuth scopes exist for a personal API key.
  • The key is account-wide. There is no per-publication key.
  • To publish under an organization, the account must already be a member of that organization and the organization id must be supplied in the post settings.

Sandbox / testing mode

Dev.to has no sandbox environment. The API acts on the production account.

  • Use a secondary Dev.to account for tests, or accept that a test article is public.
  • Rustiz always sends published: true; there is no draft mode in this provider. The provider’s “schedule” is Rustiz waiting until the scheduled time before calling Dev.to.
  • A useful safe check is GET /api/users/me, which creates nothing.

Production app review

There is no app review. The same key works in production immediately. Dev.to’s general terms and per-endpoint rate limits still apply.

Connecting a Channel (CLI & MCP)

The credential payload has exactly one key:

KeyTypeValidation regexNotes
apiKeypassword/^.{3,}$/Dev.to personal API key

CLI

Create a JSON file (or pipe JSON on stdin with --fields -):

{ "apiKey": "dv_xxxxxxxxxxxxxxxxxxxxxxxx" }
rustiz channels connect devto --fields devto-key.json

# or without a file
printf '%s' '{"apiKey":"dv_xxxxxxxxxxxxxxxxxxxxxxxx"}' \
  | rustiz channels connect devto --fields -

A successful credential connection is immediate and returns:

{ "connected": true, "pending": false, "name": "Your Dev.to name" }

MCP

connectChannelTool arguments:

{
  "provider": "devto",
  "customFields": {
    "apiKey": "dv_xxxxxxxxxxxxxxxxxxxxxxxx"
  }
}

There is no follow-up step. Call integrationList to get the channel id. The returned connection object is {"type":"customFields","headless":true,...} in integrationSchema. Use --fields / customFields; --external-url / externalUrl is not the credential path for this provider.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
tagsnone[{"value": <number>, "label": "<name>"}]Calls GET /api/tags?per_page=1000&page=1 with the stored key. value is the numeric Dev.to tag id.
organizationsnone[{"id": <number>, "name": "...", "username": "..."}]Reads up to 1000 of the user’s articles from /api/articles/me/all, collects distinct organization.username, then calls /api/organizations/{username} once per organization.

MCP examples:

{ "integrationId": "int_...", "methodName": "tags" }
{ "integrationId": "int_...", "methodName": "organizations" }

Both functions have an empty input_schema / dataSchema.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
titlestringYesminLength: 2. Also checked by validate_settings.
main_imageobjectNoCover image. Properties: id (string, required by the schema), path (string, required), alt (string), thumbnail (string URL). additionalProperties: true.
canonicalstringNoCanonical URL sent as canonical_url. If it contains (post: it is skipped by URL validation.
organizationstringNoDev.to organization id sent as organization_id. Leave empty to publish as the user.
tagsarrayYes (schema)maxItems: 4; each item needs value (number, tag id) and label (string, tag name). Only label is sent to Dev.to.

The schema marks title and tags as required. validate_settings is more lenient about a missing tags value (it treats it as an empty list), but the schema and normal editor flow require it.

Example object form (public API / CLI --json):

{
  "title": "Shipping a Rust scheduler",
  "main_image": { "id": "m1", "path": "https://cdn.example/cover.png" },
  "canonical": "https://blog.example/shipping-rust-scheduler",
  "organization": "1234",
  "tags": [
    { "value": 1, "label": "rust" },
    { "value": 2, "label": "api" }
  ]
}

MCP settings is an array of {key, value} pairs:

{
  "settings": [
    { "key": "title", "value": "Shipping a Rust scheduler" },
    { "key": "tags", "value": [
      { "value": 1, "label": "rust" },
      { "value": 2, "label": "api" }
    ] }
  ]
}

The engine adds "__type": "devto" automatically if it is missing.

validate_settings() rules

  1. title must exist and be a string; at least 2 Unicode scalar values.
  2. canonical, if present and a string, must be a valid URL unless it contains the internal (post: placeholder. The Rust URL check accepts an empty string, an http(s):// URL, or a www. host with a dot and a non-space tail.
  3. organization, if present and not null, must be a string.
  4. tags, if present and not null, must be an array with at most 4 items. Each item must be an object with a numeric value and a string label.
  5. main_image, if present and not null:
    • must be an object;
    • id must be a string;
    • path must be a string;
    • if RESTRICT_UPLOAD_DOMAINS is set, path must contain that value;
    • the path (query string stripped) must end in .png, .jpg, .jpeg, .gif, .webp or .mp4 (case-insensitive);
    • alt, if present, must be a string;
    • thumbnail, if present, must be a non-empty valid URL.

Media requirements

  • Dev.to only receives a cover image URL in main_image.path; the media / attachments array on the post is ignored.
  • The cover is not uploaded by Rustiz. It must already be a public URL.
  • The extension check allows .mp4, but Dev.to’s main_image is a cover image; a video URL will almost certainly be rejected by the platform.
  • Body images are whatever Markdown the post contains (Dev.to side-loads external images referenced in the article).

check_validity() is the trait default (Ok(None)). Dev.to has no additional media-count, dimension or file-type rule in this provider; the only media validation is the main_image extension/domain check in validate_settings().

Plugs

No @Plug or @PostPlug methods are implemented. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • The article is always public. post() hard-codes published: true. There is no draft or native scheduling option. A scheduled post is only delayed by Rustiz; once the publish job runs, the article is live.
  • No Accept header. Forem’s v1 documentation says v1 requests should send Accept: application/vnd.forem.api-v1+json. Rustiz sends only api-key. The tests and ordinary api-key endpoints tolerate this, but if Dev.to returns an unexpected response or HTML, missing version negotiation is the first thing to check.
  • Organization id is sent as a JSON string. The schema and validation require organization to be a string, and post() sends "organization_id": "<string>". Dev.to’s article schema documents organization_id as an integer. If Dev.to rejects the organization, this type mismatch is the cause. Omitting organization avoids it.
  • (post: canonical placeholder is only skipped in validation. If a caller passes a string containing (post:, validate_settings accepts it, but post() still sends the non-empty value as canonical_url. Only omit the field to leave the canonical URL out.
  • Tag ids are replaced by labels. The tool returns {value: <id>, label: <name>}, but publishing sends only label. Renaming a tag in the editor changes what is sent; the numeric id is not used by Dev.to on create.
  • tags[].value must be a number in Rustiz. docs.postiz.com shows string values in some examples. The Rust validator explicitly requires Value::is_number; string tag values fail with each tag needs a numeric \value` (tag id)`.
  • organizations is an N+1 call and capped at 1000 articles. It reads /api/articles/me/all?per_page=1000; organizations that never appear on those articles are not listed. Large accounts can be slow.
  • No refresh. authenticate stores the key with a 100-year expires_in and refresh_token: Some(""). refresh_token() returns an empty access token with expires_in: 0. A genuine 401 therefore cannot be repaired by refresh; reconnect the channel with a fresh key.
  • 401 maps to refresh. The HTTP layer treats an unclassified 401 as RefreshToken (test unauthorized_is_a_refresh), so a revoked key shows up as a refresh/reconnect problem.
  • Canonical uniqueness. A 422 body containing Canonical url has already been taken is converted to the actionable message Canonical URL already exists.
  • Max concurrency 3. The provider limits itself to three simultaneous jobs. Dev.to also enforces its own per-endpoint limits (article creation is commonly limited to 10 requests per 30 seconds); the generic HTTP layer retries 429/500 up to three times with the configured 5-second delay.

References

Discord (discord)

Discord is a guild-based chat platform. Rustiz uses OAuth only to discover the guild and the application’s bot user; every publish, comment and mention call authenticates as the Rustiz bot with DISCORD_BOT_TOKEN_ID. A channel is a guild channel id, and comments are published in a thread created on the original message.

  • Rust source: crates/rustiz-providers/src/discord.rs
  • Upstream port: discord.provider.ts
  • Editor: Markdown
  • Connection mode: oauth

Overview

PropertyValue
Identifierdiscord
Display nameDiscord
Connection modeoauth (a human opens Discord’s consent/install page)
Editormarkdown
Channel kindsocial
Comments / threadsYes. supports_comments = true; the first comment opens a thread on the post, and later comments are intended to stay in that thread (chains of 3+ entries are broken; see Gotchas)
Pending / async publishingNo. supports_pending = false; posting is a single multipart request
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsYes. supports_mentions = true; roles, members, @here and @everyone
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job5 — generous for webhook-style message posting
Character limit (max_length)1980 characters (Discord’s hard limit is 2000)
OAuth scopesidentify, guilds checked; the authorize URL also requests bot
Redirect URI{FRONTEND_URL}/integrations/social/discord
API hosthttps://discord.com (override DISCORD_BASE_URL)
CDN hosthttps://cdn.discordapp.com (override DISCORD_CDN_BASE_URL)

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
DISCORD_CLIENT_IDYes—Application id from the Developer Portal
DISCORD_CLIENT_SECRETYes—OAuth2 client secret
DISCORD_BOT_TOKEN_IDYes in practice—The bot token, despite the _ID suffix. Every message, comment, channel list, mention and nickname call uses Bot <token>. Not part of required_env().
DISCORD_BASE_URLNohttps://discord.comOrigin for OAuth and /api/... calls, and for release URLs. Trailing slash stripped.
DISCORD_CDN_BASE_URLNohttps://cdn.discordapp.comOrigin for avatar URLs (/avatars/{user}/{hash}.png). Trailing slash stripped.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the OAuth redirect URI
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state row

rustiz providers --status reports Discord as configured when only DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET are set. It does not check DISCORD_BOT_TOKEN_ID. The OAuth login can succeed and the channel can appear in channels list, but every publish fails with 401: Unauthorized until the bot token is set. Set both credentials and the bot token.

DISCORD_BASE_URL is used for both API calls and browser-facing release URLs. If you point it at a mock server, the releaseURL values are mock URLs too.

Step-by-Step Developer App Setup

1. Create the application

  1. Sign in to Discord and open the Developer Portal.
  2. Click New Application, name it (e.g. Rustiz), accept the terms.
  3. Upload an App Icon (1024×1024 max) under General Information and save. Postiz’s own docs call this required: without an icon, the Discord CDN avatar URL that Rustiz builds can 404 when the channel is added.
  4. Open OAuth2 and copy the Client ID and Client Secret.

2. Add the redirect URI

In OAuth2 → Redirects, click Add Redirect and paste the exact value:

https://social.example.com/integrations/social/discord

Local development examples:

http://localhost:5000/integrations/social/discord   # Docker / rustiz serve default
http://localhost:4200/integrations/social/discord   # upstream Postiz dev UI

Discord requires HTTPS for production redirect URIs. It accepts http://localhost (and sometimes loopback IPs) for local development. The URI must match exactly, including scheme, host, port, path and trailing slash. Do not append a query string.

There is no redirectmeto.com wrapper for Discord.

3. Create the bot and copy the token

  1. Open Bot in the Developer Portal.
  2. Set the bot’s username/avatar as desired.
  3. Click Reset Token and copy the token. This is the value for DISCORD_BOT_TOKEN_ID — it is a token, not an application id.
  4. The bot does not need privileged intents for this integration: it sends messages and uses the member search endpoint, which Discord documents as usable without the Guild Members intent. Do not enable privileged intents unless you need the full member list or member events.

If you later want the full member list (GET /guilds/{id}/members), the GUILD_MEMBERS privileged intent is required. Apps under 10,000 guild-installed users can toggle privileged intents themselves; above that, Discord requires an application and review.

4. Set the environment and restart

DISCORD_CLIENT_ID="…"
DISCORD_CLIENT_SECRET="…"
DISCORD_BOT_TOKEN_ID="…"
FRONTEND_URL="https://social.example.com"

Restart rustiz serve, rustiz worker and rustiz mcp, then check:

rustiz providers --status

5. Redirect URI (exact)

Discord::redirect_uri() is:

#![allow(unused)]
fn main() {
format!(
    "{}/integrations/social/discord",
    self.ctx.config.frontend_url()
)
}

FRONTEND_URL is trimmed of trailing slashes, so the registered value has no trailing slash. The same string is sent to /oauth2/authorize and to /api/oauth2/token; a mismatch returns invalid_request / Invalid "redirect_uri" in request.

6. Scopes and permissions requested

The authorize URL is:

https://discord.com/oauth2/authorize
  ?client_id=<DISCORD_CLIENT_ID>
  &permissions=377957124096
  &response_type=code
  &redirect_uri=<urlencoded redirect>
  &integration_type=0
  &scope=bot+identify+guilds
  &state=<state>

377957124096 is the permission bit set:

PermissionBitIncluded
SEND_MESSAGES1 << 11yes
CREATE_PUBLIC_THREADS1 << 35yes
CREATE_PRIVATE_THREADS1 << 36yes
SEND_MESSAGES_IN_THREADS1 << 38yes
VIEW_CHANNEL1 << 10no (normally granted by @everyone)
ATTACH_FILES1 << 15no (needed for media; grant via role if @everyone lacks it)

integration_type=0 means GUILD_INSTALL. The app must allow guild installs in the Developer Portal (the default for a bot app). The bot scope adds the bot to the selected server with the requested permissions; identify and guilds give Rustiz the user token it uses to read the guild id and application/bot identity.

authenticate() calls check_scopes() with ["identify", "guilds"] against the token response’s scope. The bot scope is not checked, but the URL requests it. If Discord returns a scope string without identify or guilds, the connection fails with NotEnoughScopes.

The user performing the install needs Manage Server on the target guild.

7. Sandbox / testing vs production app review

Discord has no sandbox guild or test-mode OAuth client. Use a dedicated test server.

  • Under 100 guilds: the bot works immediately; no Discord review. Create a private test server, install the app there, and post to a test channel.
  • 100+ guilds: Discord App Verification is required. The owner must verify identity through Stripe and complete the App Verification checklist in the Developer Portal. This is for public scale, not for a private deployment.
  • App Directory listing is optional and separate from verification.
  • Privileged intents: not needed here; if enabled, the threshold is 10,000 guild-installed users (2026 policy), after which Discord requires an application and 90-day review.
  • Bot token vs OAuth token: production and test use the same application model. Rotating the bot token in the portal immediately breaks every existing Rustiz channel until DISCORD_BOT_TOKEN_ID is updated and the processes are restarted. Reconnecting the channel does not fix a bad bot token; Rustiz’s own error says so.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect discord --open
  • The login URL is printed to stderr; --open launches the browser.
  • With a local FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to --timeout seconds (default 300) for the new channel.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.

The connect response’s id is the guild id, name is the application name, and username is the bot user’s username. The channel’s internal_id is the guild id.

MCP connectChannelTool

Start:

{ "provider": "discord" }

The tool returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://discord.com/oauth2/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Give the url to a human with Manage Server, then poll:

{ "connectionId": "…" }

Choosing the channel after connecting

is_between_steps = false; there is no channelSetupTool step. The guild id is already stored. Use triggerTool with the channels function to list the guild’s text, announcement and forum channels:

{
  "integrationId": "<discord channel id>",
  "methodName": "channels",
  "dataSchema": []
}

The result is [{ "id": "123456789012345678", "name": "general" }]. Only channel types 0 (text), 5 (announcement) and 15 (forum) are returned. Choose an id and put it in the post settings:

{ "channel": "123456789012345678" }

channelSetupTool is not used by Discord. It is only for providers whose connection creates a temporary parent channel that must be narrowed to a page, board, company or YouTube channel.

What not to send

  • customFields — Discord is OAuth only; the generic path would treat the object as an authorization code.
  • externalUrl — Discord is not self-hosted. The generic path calls the provider’s external_url(), which returns Unsupported("externalUrl").

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "channel": {
      "type": "string",
      "minLength": 1,
      "description": "Channel must be an id"
    }
  },
  "required": ["channel"],
  "additionalProperties": true
}

channel is a snowflake id, not a #name. Usernames and channel names are not resolved.

validate_settings() rules

  • channel must exist and be a string.
  • It must not be empty.
  • No snowflake-format check, no permission check, no “is this channel in the connected guild?” check. A wrong or foreign channel id passes validation and fails at Discord with 50001 / 10003.

check_validity()

Not overridden. There are no pre-flight media or length rules; Discord’s API enforces them. In particular:

  • The 2000-character message limit is reduced to 1980 by max_length(), but a post with media still has the same 2000-character cap.
  • Discord’s attachment size limit depends on the server’s boost tier. Rustiz does not pre-check; Discord returns 40005 (Request entity too large) and the provider maps it to “Attachment exceeds Discord’s size limit”.

Media requirements

send_message_with_media() builds a multipart request:

POST {DISCORD_BASE_URL}/api/channels/{channel}/messages
Authorization: Bot <DISCORD_BOT_TOKEN_ID>
Content-Type: multipart/form-data

payload_json = {
  "content": "<converted mentions>",
  "attachments": [
    {"id": 0, "description": "Picture 0", "filename": "image.png"},
    ...
  ]
}
files[0] = <streamed file>
files[1] = ...
  • Every media item is attached in one message. Discord allows up to 10 attachments per message; Rustiz does not split a larger list.
  • The file part has no explicit MIME type. Discord usually sniffs it, but a mislabeled file can be rejected.
  • The whole form is rebuilt on each retry because a consumed media stream cannot be replayed.
  • Discord’s per-file size limit applies (10 MB on unboosted servers; higher with boosts). There is no pre-flight size check.
  • alt text is not sent; the attachment description is hard-coded as Picture <index>.

Threads and comments

supports_comments = true.

  • The first comment calls POST /api/channels/{channel}/messages/{post_id}/threads with {"name":"Thread","auto_archive_duration":1440} and then sends the comment into the new thread channel.
  • Later comments are supposed to reuse the same thread channel.
  • Known limitation: comment() receives last_comment_id as the previous comment’s message id, and then uses it as a channel id for the next POST /channels/{id}/messages. For a thread with three or more entries, the third entry targets a message id as if it were a channel and fails. Only the first reply is reliable. See “Implementation Notes” below.
  • Comment media follows the same multipart path as a normal post.
  • releaseURL for a comment is the thread message URL.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Posting is bot-only. post() and comment() ignore the OAuth access_token entirely and send Authorization: Bot <DISCORD_BOT_TOKEN_ID>. The OAuth token is used only during connect/refresh. A missing or rotated bot token makes every publish fail even though the channel looks connected.
  • The bot must be in the guild. The bot scope in the authorize URL adds it during install. If someone removes the bot from the server, channels returns 50001 and posts fail. Reconnecting the channel does not re-add the bot; use the install URL again.
  • Channel lookup uses the guild id. channels() calls /api/guilds/{integration.internal_id}/channels. If the token response did not include guild.id (for example the bot was already installed and Discord omitted the guild object), internal_id is empty and the lookup hits /api/guilds//channels. This is why the integration id is the guild id, not the bot id.
  • Avatar URL can 404. authenticate() builds {CDN}/avatars/{bot.id}/{bot.avatar}.png. If the bot has no custom avatar, bot.avatar is empty and the URL ends in /.png; Postiz’s docs tell you to upload an app icon for this reason. A 404 avatar does not stop publishing.
  • Refresh response loses identity fields. refresh_token() returns only the new token and application name; id, picture and username are empty. The engine only writes the token columns, so this is normally harmless. Do not call refresh_token() and then treat the result as a full account snapshot.
  • OAuth token lifetime. Discord access tokens expire after 7 days (expires_in: 604800) and the refresh token can rotate. The engine does not refresh Discord proactively (refresh_cron = false); it refreshes on a 401 from the OAuth token. Because posting uses the bot token, a stale OAuth token has no effect on publishing.
  • Thread bug for 3+ comments. Engine chaining (post_flow.rs) passes last_comment_id = previous comment's post_id. For Discord that is a message id. comment() treats it as a channel id on every reply after the first. With exactly two entries (post + one comment) the code uses the root post id and works. With three or more, the third and later comments fail. There is no stored thread_id in pending_data/PostResponse, so the provider cannot recover the thread channel id later.
  • Forum channels are listed but not really supported. channels() includes type 15 (forum), but posting to a forum channel requires creating a thread with a thread_name/forum payload, not a plain /channels/{id}/messages call. Expect a forum channel to fail or create an unexpected message. Prefer text (0) or announcement (5) channels.
  • Mentions: role labels are often empty. mention() returns roles with id = "&<role id>" and label = name.split('@').nth(1). A normal role name like Moderator has no @, so the label is an empty string. The id is still correct and mention_format() converts [[[@&123]]] to <@&123>, but a UI that displays label will show blank role options.
  • Mentions: member search needs the bot in the guild. members/search is called with the bot token and a query; it returns members whose username or nickname starts with the query. No privileged intent is required by current Discord docs for this endpoint (the full member-list endpoint is different). @here and @everyone are special-cased and not cached.
  • Mentions: @here/@everyone are plain text. mention_format() returns the name unchanged for those two, unlike normal users/roles which are wrapped as [[[@…]]] and converted to <@…>/<@&…>. A ping only happens if the bot’s role has MENTION_EVERYONE; the fixed permission integer 377957124096 does not request it. Grant it separately if you need mass mentions.
  • Mention conversion is regex-based. convert_mentions() replaces [[[@...]]] with <@...> for both users and roles. A literal [[[@...]]] in the message will be converted; there is no escaping mechanism.
  • Error mapping is substring-based. handle_errors() looks for Discord error codes anywhere in the body:
    • 50001 → Bot doesn't have access to this channel
    • 50013 → Bot lacks permission to send messages in this channel
    • 10003 → Channel no longer exists
    • 40005 → Attachment exceeds Discord's size limit
    • 20028 → Rate limited by Discord (retry)
    • 401: Unauthorized → Discord rejected the Rustiz bot token, reconnecting will not help A 401 with a different body is treated as a refresh error by the shared HTTP layer, which then cannot help because posting uses the bot token.
  • Only 200/201 count as success in send(). send_message_with_media bypasses this and accepts 200..=202. channels, create_thread and change_nickname use send_json, so a 204 would be treated as failure.
  • Rate limits. Discord allows up to 50 requests/second per bot globally, plus per-route buckets (message creation is roughly 5 requests per 5 seconds per channel). Rustiz sets max_concurrent_job = 5, which is safe for global throughput but can still hit a per-channel bucket if several queued posts target the same channel. 429 responses are retried by the shared HTTP layer.
  • Invalid-request Cloudflare ban. Discord temporarily blocks IPs that make too many 401/403/429 requests (10,000 per 10 minutes). A bad bot token or a removed channel in a retry loop can contribute. Fix the token/channel instead of letting the queue retry indefinitely.
  • change_nickname needs the guild id and permission. It calls PATCH /api/guilds/{id}/members/@me with {"nick": name}. The requested permission bit set does not include CHANGE_NICKNAME; a bot can normally change its own nickname, but a restrictive role setup may return 403.
  • max_length is 1980, not 2000. The 20-character margin is intentional, but the provider does not count UTF-16 code units or grapheme clusters the way Discord does. Emoji-heavy posts can still exceed Discord’s real limit.
  • Markdown editor. Discord renders Markdown, so the engine passes the content through the Markdown editor path. Discord’s flavor differs from CommonMark in tables, headings and mentions; test formatting in a real channel.
  • Release URL uses the configurable base. It is {DISCORD_BASE_URL}/channels/{guild}/{channel}/{message}. With the default base this is the normal discord.com/channels/... link; with a mock base it is not a usable Discord link.
  • No analytics or plugs. Discord has no analytics methods, no post analytics and no plug hooks in this port.

References

Dribbble (dribbble)

Dribbble is an OAuth 2.0 provider for publishing a single shot image. The API is a single-shot multipart upload: one image is posted to POST https://api.dribbble.com/v2/shots, and the new shot id is read from the Location response header. Dribbble creates shots asynchronously, but the Rustiz port marks the post Completed as soon as the API accepts the upload.

  • Rust source: crates/rustiz-providers/src/dribbble.rs
  • Upstream port: dribbble.provider.ts
  • Editor: Normal (plain text)
  • Connection mode: oauth

Read the gotchas before using this provider. The refresh path is a faithful port of an upstream copy/paste bug that calls Pinterest with Pinterest credentials and a Pinterest redirect URI. The team setting is validated but never sent. See Implementation Notes & Gotchas.

Overview

PropertyValue
Identifierdribbble
Display nameDribbble
Connection modeoauth (authorization code; no refresh token in practice)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first PostDetails is published
Pending / async publishingNo. supports_pending = false; Dribbble’s 202 + Location is treated as completed immediately
Analyticssupports_analytics = true, but analytics() returns an empty array. The flag advertises the capability; there is no actual data.
Post analyticssupports_post_analytics = true, but post_analytics() returns an empty array.
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)40000 characters for the shot description/body
APIDribbble API v2

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["DRIBBBLE_CLIENT_ID", "DRIBBBLE_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden. provider_status will refuse to generate a login URL if either is empty.

VariableRequiredDefaultPurpose
DRIBBBLE_CLIENT_IDYesnoneOAuth application client id.
DRIBBBLE_CLIENT_SECRETYesnoneOAuth application client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the OAuth redirect URI. Must be the public origin Dribbble can reach; a trailing slash is stripped.
DRIBBBLE_OAUTH_BASE_URLNohttps://dribbble.comOverrides the authorize/token origin. The paths appended are /oauth/authorize and /oauth/token.
DRIBBBLE_BASE_URLNohttps://api.dribbble.comOverrides the API origin. Paths appended are /v2/user and /v2/shots.
PINTEREST_BASE_URLNohttps://api-sandbox.pinterest.comOnly used by the broken refresh path. It does not affect normal publishing.
PINTEREST_CLIENT_IDNononeRead only by the broken refresh path; not declared in required_env().
PINTEREST_CLIENT_SECRETNononeRead only by the broken refresh path; not declared in required_env().

required_env() deliberately omits PINTEREST_*, even though refresh_token() reads them. That means rustiz providers --status says Dribbble is configured while refresh support is silently incomplete.

Step-by-Step Developer App Setup

  1. Sign in to Dribbble and open https://dribbble.com/account/applications/new.

  2. Fill in:

    • Name — for example Rustiz.

    • Description — required by the form.

    • Website — your product or instance URL.

    • Callback URL / Redirect URI — enter exactly:

      {FRONTEND_URL}/integrations/social/dribbble
      

      For example https://social.example.com/integrations/social/dribbble. Do not add a trailing slash.

  3. Save the application and copy the Client ID and Client Secret. The Postiz Dribbble guide says both are 64 characters long.

  4. Set them on the server:

    DRIBBBLE_CLIENT_ID="your-client-id"
    DRIBBBLE_CLIENT_SECRET="your-client-secret"
    FRONTEND_URL="https://social.example.com"
    
  5. Restart Rustiz and check:

    rustiz providers --status | jq '.[] | select(.identifier=="dribbble")'
    

    configured should be true with no missing variables.

Spelling warning. The Postiz documentation page for Dribbble currently shows DRIBBLE_CLIENT_ID / DRIBBLE_CLIENT_SECRET (two bs, not three) and a redirect path /integrations/social/dribble (one fewer b). Those are typos. This Rust port uses DRIBBBLE_CLIENT_ID / DRIBBBLE_CLIENT_SECRET and the correct /integrations/social/dribbble path. Register the URI the Rust code actually builds, not the one on that docs page.

Products and APIs

Dribbble has no products or API-enablement console. Registering an OAuth application is enough. The scopes are requested in the authorization URL.

Exact OAuth scopes

scopes() returns:

#![allow(unused)]
fn main() {
vec!["public", "upload"]
}

The authorization URL sends scope=public+upload (a space-separated list, encoded as +). authenticate() calls check_scopes(&["public", "upload"], token["scope"]); the token response must contain both scopes. upload is the one that permits POST /v2/shots; public is read-only public information.

Redirect URI details

The exact URI is:

{FRONTEND_URL (trailing slash trimmed)}/integrations/social/dribbble

Examples:

DeploymentRedirect URI to register
Production https://social.example.comhttps://social.example.com/integrations/social/dribbble
Local default FRONTEND_URLhttp://localhost:5000/integrations/social/dribbble
A local frontend on port 4200http://localhost:4200/integrations/social/dribbble
A Docker frontend on port 5000http://localhost:5000/integrations/social/dribbble

Dribbble’s own OAuth docs say the redirect host and port must exactly match the registered callback; if redirect_uri is omitted, the registered callback is used. Rustiz always sends it, both at authorize time (URL-encoded) and at token exchange (unencoded; see gotchas). There is no redirectmeto.com relay in this provider: the value is built directly from FRONTEND_URL, so an HTTP local frontend registers an HTTP URI and an HTTPS production frontend registers an HTTPS URI.

Sandbox / testing mode

Dribbble has no sandbox environment and no separate test app mode.

  • The app is usable immediately after registration; there is no documented review/approval queue.
  • Use a secondary Dribbble account for tests. A shot created through the API is public; there is no draft status in the create-shot API.
  • scheduled_for exists in Dribbble’s API but requires a Pro account, team membership or a team; Rustiz does not expose it. The scheduler controls when Rustiz calls the API.
  • Rate limits are 60 requests per minute and 1,440 per day per authenticated user. Watch X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 is returned when exceeded.
  • The application can be suspended for abuse, spam or API misuse. That is a platform enforcement action, not a pre-publication review.

Production app review

No separate app-review step is documented in the OAuth flow: registration is self-serve and the app can authorize users immediately. Dribbble’s API terms reserve the right to refuse API access at any time and to throttle or block a service, and an app can be suspended for abuse or API misuse. For a public or commercial integration, treat access as discretionary and read the API terms before launch.

Connecting a Channel (CLI & MCP)

CLI

# Opens the Dribbble authorization page in the default browser.
rustiz channels connect dribbble --open

# Without --open: the URL is printed to stderr and the command waits.
rustiz channels connect dribbble

# Remote server: open the URL yourself, then poll.
rustiz channels connect-status <connectionId>

In local/in-process mode the CLI serves the callback on the port of FRONTEND_URL (if it is this machine) and waits for the new channel. On success it prints the channel JSON.

MCP

Start the OAuth flow:

{ "provider": "dribbble" }

The tool returns either a URL elicitation (for clients that support it) or:

{
  "connected": false,
  "pending": true,
  "connectionId": "abc123",
  "url": "https://dribbble.com/oauth/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Give the URL to a human, then poll:

{ "connectionId": "abc123" }

There is no follow-up step: is_between_steps is false. Call integrationList for the channel id.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
teamsnone[{"id": <number>, "name": "..."}]Calls GET /v2/user and maps teams. If the user has no teams, returns [].

MCP:

{ "integrationId": "int_...", "methodName": "teams" }

The function has an empty input_schema / dataSchema.

The teams result cannot currently be used for posting. The team setting is ignored by post(), and Dribbble requires a numeric team_id field in the shot upload, which the provider never sends. Treat teams as informational only.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
titlestringYesminLength: 1; must be a non-empty string. No maximum is enforced by Rustiz. This is Dribbble’s required shot title.
teamstringNoDescribed as a team URL/id. validate_settings requires a valid URL when the value is present and non-null. It is never read by post(). An empty string fails validation; omit the field or use null to post on the personal profile.

There is no description, tags, low_profile, rebound_source_id or scheduled_for setting. The post body (PostDetails.message) becomes the shot description.

Example object form:

{
  "title": "Dashboard UI — dark mode",
  "team": null
}

MCP settings array:

{
  "settings": [
    { "key": "title", "value": "Dashboard UI — dark mode" }
  ]
}

The engine adds "__type": "dribbble" automatically if missing.

validate_settings() rules

  1. title must exist and be a string; it must not be empty.
  2. team, if present and not null, must be a string and must parse as a URL.
    • An empty string is not a URL and fails with team must be a URL. This matters because docs.postiz.com examples use "team": "".
    • Use null or omit team for a personal shot.
  3. There is no check that the team URL belongs to the authenticated user, and no conversion from a team URL to Dribbble’s numeric team_id.

check_validity() rules

check_validity() is called before scheduling/publishing with each part’s media. It looks only at the first post part:

  1. If any item’s path contains "mp4" (case-sensitive), return Does not support mp4 files.
  2. Otherwise, the part must have exactly one media item. Zero or two or more returns Requires one item.
  3. It reads the image dimensions and accepts only:
    • 400×300, or
    • 800×600. Anything else returns Invalid image size. Requires 400x300 or 800x600 px images.

Because the MP4 check runs before the count check, a two-item list containing an MP4 returns the MP4 message, not Requires one item.

The validation does not check:

  • file size (Dribbble’s limit is 8 MB);
  • actual file type (Dribbble accepts GIF, JPG or PNG);
  • file extension;
  • whether the image is animated or has the right color profile.

A 400×300 WebP or a 400×300 20 MB PNG passes Rustiz validation and is then rejected by Dribbble’s API.

Media and publish behavior

  • Exactly one media item is used: first.media.first(). All other media is ignored.
  • The file is streamed as multipart form data under the field name image, with the original filename and a MIME type inferred from the extension (jpg/jpeg, png, gif, webp, mp4; unknown → no MIME part).
  • The form also sends:
    • title from settings.title;
    • description from first.message.
  • It does not send team_id, tags, low_profile, rebound_source_id or scheduled_for.
  • Dribbble’s API responds 202 Accepted with a Location header such as https://api.dribbble.com/v2/shots/471756. Rustiz treats any 2xx as success, takes the last path segment as the post id, and returns https://dribbble.com/shots/{id}. The shot may return 404 at that URL until Dribbble finishes processing, because creation is asynchronous.
  • If the 2xx response has no Location, the post id is empty and the release URL becomes https://dribbble.com/shots/ — a silent bad success.
  • A 4xx other than 429 is converted to a bad_body error using the platform message field, or Dribbble rejected the shot with status {status} if there is no message. 429 is passed to the generic retry path.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

The refresh path calls Pinterest (upstream bug, faithfully ported)

This is the most important thing to know about dribbble.rs. The module header says it explicitly:

Faithful port note: upstream refreshToken calls the Pinterest sandbox endpoints with PINTEREST_CLIENT_ID / PINTEREST_CLIENT_SECRET. That copy paste bug is kept here.

In refresh_token() (around lines 258–318):

  1. It builds Basic auth from PINTEREST_CLIENT_ID and PINTEREST_CLIENT_SECRET.

  2. It posts to {PINTEREST_BASE_URL}/v5/oauth/token, default https://api-sandbox.pinterest.com/v5/oauth/token.

  3. The form contains grant_type=refresh_token, the Dribbble refresh token, scope=public,upload, and a redirect_uri of:

    {FRONTEND_URL}/integrations/social/pinterest
    

    That is pinterest, not dribbble — line 283 of dribbble.rs.

  4. It then calls {PINTEREST_BASE_URL}/v5/user_account with the returned token and returns a AuthTokenDetails whose id, name, username and picture are Pinterest account fields.

Consequences:

  • A Dribbble refresh will fail unless Pinterest sandbox credentials are also configured, and it will send the Dribbble refresh token to Pinterest.
  • Even if it succeeds, the token is a Pinterest token and the returned identity is a Pinterest user; it cannot be used to post to Dribbble.
  • PINTEREST_CLIENT_ID / PINTEREST_CLIENT_SECRET are not declared in required_env(), so the deployment’s provider status will not warn about them.
  • Normal Dribbble operation tries to avoid refresh: authenticate() sets expires_in: Some(999999999) (about 31.7 years) and refresh_token: Some(""). Dribbble’s token response does not document a refresh token. A 401 still enters the refresh path, where this bug fires.
  • Workaround: do not rely on token refresh. If a Dribbble channel is marked refresh-needed or starts returning 401, reconnect it through rustiz channels connect dribbble rather than trying to repair the token.

Other gotchas

  • The team setting is dead code. It is in the schema and validated, but post() never reads it. Dribbble’s create-shot API wants a numeric team_id; the teams tool returns numeric ids, but there is no path from the setting to the request. Team shots cannot be published by this provider.

  • "team": "" fails validation. The docs examples use an empty string, but is_url("") is false, so the call fails with team must be a URL. Omit the key or send null.

  • The token exchange query is hand-built and unencoded. authenticate() constructs:

    POST {DRIBBBLE_OAUTH_BASE_URL}/oauth/token?client_id=...&client_secret=...&code=...&redirect_uri=...
    

    The authorization code and redirect URI are not URL-encoded. Dribbble authorization codes are normally URL-safe and the redirect URI has no query string, so this usually works, but a client secret or code containing &, # or + would break the request.

  • expires_in is synthetic. Dribbble’s OAuth token response contains access_token, token_type and scope, but no expires_in. Rustiz stores 999999999 seconds so the engine will not try to refresh. This is a deliberate workaround, not data from Dribbble.

  • No refresh token is stored. authenticate() sets refresh_token: Some(""). Any engine refresh attempt passes an empty string to the Pinterest bug. There is no way to recover a Dribbble token through this provider.

  • analytics flags are misleading. info.supports_analytics and info.supports_post_analytics are true, so generic callers may ask for analytics, but both methods return Ok(vec![]). Dribbble does not expose detailed shot analytics through this API.

  • Creation is asynchronous. Dribbble returns 202 + Location; the shot may 404 for a short time while it is processed. Rustiz marks the post Completed immediately. A later analytics/post lookup at the returned https://dribbble.com/shots/{id} may briefly fail.

  • Missing Location is a silent empty success. The code does not treat a 2xx without Location as an error; it returns an empty post id and https://dribbble.com/shots/. Check the response if a Dribbble post appears successful with no URL.

  • Rate limits are per authenticated user. 60 requests/minute and 1,440/day. max_concurrent_job = 3 keeps concurrency low, but retries and multiple channels on one account share the limit. 429 is retried by the generic HTTP layer up to three times; there is no provider-specific handle_errors() mapping.

  • Validation is dimensions-only. See the media section: size, MIME and extension are unchecked. Use 800×600 PNG/JPG under 8 MB for the most reliable result.

  • Description is plain text. The editor is normal, so HTML is stripped before message reaches Dribbble. Tags are not sent; put hashtags in the description if desired.

  • The release URL is constructed, not read from Dribbble. It is always https://dribbble.com/shots/{id}; Dribbble’s html_url field is not used.

References

Facebook Page (facebook)

Facebook Page publishing uses the Meta Graph API with a Facebook Login for Business token. Rustiz stores the user’s long-lived token at connect time, then the is_between_steps setup step exchanges it for a Page access token for the chosen Page. All publishing happens as the Page, never as the personal profile.

  • Rust source: crates/rustiz-providers/src/facebook.rs
  • Upstream port: facebook.provider.ts
  • Editor: Normal
  • Connection mode: oauth (Facebook Login for Business)
  • Related providers: instagram and instagram-standalone share the same Meta app family, but only instagram reuses FACEBOOK_APP_ID / FACEBOOK_APP_SECRET

Overview

PropertyValue
Identifierfacebook
Display nameFacebook Page
Connection modeoauth (Facebook Login for Business)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries are posted as POST /{post-id}/comments
Pending / async publishingYes for stories (supports_pending = true); ordinary feed posts are published synchronously inside post_pending() and return PostStatus::Success
AnalyticsYes. supports_analytics = true; Page Insights
Post analyticsYes. supports_post_analytics = true; feed post insights or video insights
MentionsNo. supports_mentions = false
is_between_stepstrue — the OAuth callback creates an unfinished channel; a Page must be selected with channelSetupTool
convert_to_jpegfalse
max_concurrent_job500 (the code comment: “Facebook has reasonable rate limits”)
Character limit (max_length)63,206 characters
Graph API versionv25.0 (META_GRAPH_API_VERSION)
API basehttps://graph.facebook.com (FACEBOOK_BASE_URL)

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
FACEBOOK_APP_IDYes—Meta app ID shown at the top of the App Dashboard. Used as client_id in the login dialog and token exchange.
FACEBOOK_APP_SECRETYes—Meta app secret. Used server-side for code → token and short-lived → long-lived token exchange.
FACEBOOK_BASE_URLNohttps://graph.facebook.comGraph API origin. Config::base_url("FACEBOOK", …).
FACEBOOK_WEB_BASE_URLNohttps://www.facebook.comOrigin used for the /v25.0/dialog/oauth login URL.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the OAuth redirect URI. Trailing slash stripped.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state/client information.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard; not read directly by facebook.rs, but media URLs must be reachable by Meta.
META_GRAPH_API_VERSIONNo—Not configurable at runtime; the constant is compiled in. The table value is documentation only.

rustiz providers --status checks only that FACEBOOK_APP_ID and FACEBOOK_APP_SECRET are non-empty. It does not verify the app mode, the registered redirect URI, or that the app has the Page permissions.

Step-by-Step Developer App Setup

Facebook Page and Instagram (Facebook Business) can share one Meta app. If you already followed instagram, skip the app creation and reuse the same credentials.

1. Create the Meta app

  1. Sign in at https://developers.facebook.com/apps/creation/.
  2. Choose Other → Business as the app type. A Business portfolio is strongly recommended; public apps need Business Verification.
  3. Enter the app name, contact email and (optionally) a Business portfolio.
  4. In the App Dashboard, add the product Facebook Login for Business.
  5. Open Facebook Login for Business → Settings and add the exact Valid OAuth Redirect URI from step 2.

2. Redirect URI (exact)

Facebook::redirect_uri(None) is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/facebook", self.ctx.config.frontend_url())
}

Examples:

https://social.example.com/integrations/social/facebook   # production
http://localhost:5000/integrations/social/facebook        # Docker / rustiz serve
http://localhost:4200/integrations/social/facebook        # upstream Postiz dev UI

There is no redirectmeto.com wrapper and no trailing slash. Facebook matches the URI exactly, including scheme, host, path and any query string.

Reconnect quirk: when the OAuth start is called with ?refresh=<channel id>, authenticate() sends …/integrations/social/facebook?refresh=<id> as the redirect_uri in the token exchange, while the login dialog itself used the URI without the query. If Meta rejects the exchange with a redirect-URI mismatch, reconnect by starting a fresh connection instead of using the refresh link. Also see the page-id comparison note in the gotchas.

3. Permissions requested and checked

Facebook::scopes() returns, in this order:

#![allow(unused)]
fn main() {
vec![
    "pages_show_list",
    "business_management",
    "pages_manage_posts",
    "pages_manage_engagement",
    "pages_read_engagement",
    "read_insights",
]
}

They are joined with commas in the login URL. After the token exchange, authenticate() calls:

GET /v25.0/me/permissions?access_token=…

and collects every entry whose status is "granted", then calls check_scopes() with the comma-joined list. All six must be granted or the connect fails with NotEnoughScopes. Users can decline individual Page permissions in the login dialog, so this is a common failure.

ScopeWhy Rustiz needs it
pages_show_listList Pages in /me/accounts and Business Manager edges
business_managementRead /me/businesses, owned_pages, client_pages
pages_manage_postsCreate feed posts, photos, videos, stories
pages_manage_engagementCreate comments/replies as the Page
pages_read_engagementRead Page content and basic metadata
read_insightsPage Insights and post Insights

Missing publish_video: Meta’s Pages API documentation lists publish_video as a permission needed “if you are publishing a video to the Page”. The Rust provider does not request it. Video posts can therefore fail with (#100) No permission to publish the video, which handle_errors already maps to a friendly message. Add publish_video to the app’s requested permissions and re-authorize if you need video.

4. Page tasks and Business Manager

The app user must be able to perform the Page tasks CREATE_CONTENT, MANAGE and MODERATE on the target Page. In practice:

  • The user must be a Page admin or have full control.
  • If the Page belongs to a Business portfolio, the user must have the Page assigned in Business Manager.
  • The Page must be published; an unpublished Page cannot be posted to.
  • A Page that requires Page Publishing Authorization (PPA) must complete it before API publishing works.

pages() tries both sources:

  1. GET /v25.0/me/accounts?fields=id,username,name,access_token,picture.type(large)&limit=100
  2. GET /v25.0/me/businesses, then owned_pages and client_pages for each business, following paging.next.

Pages are deduplicated by id. A Page discovered through Business Manager but without a Page token is treated as “no permission” and produces the message “Your Facebook user has no permission to manage this page. Ask a page admin for full content access, then reconnect the channel.”

5. App roles and Development mode

  • Development mode (all new apps): only users with a role on the app (Admin, Developer, Tester) can grant permissions and see content published by the app. This is the normal setup for a single-user/self-hosted install.
  • Add testers under App roles → Roles → Testers. They must accept the invitation before they can log in.
  • In Development mode, posts with media are visible only to app role users; other people see the post without the image. This is the single most common “my image is missing” report in the Postiz docs.
  • Data created in Development mode becomes visible to everyone after switching to Live.

6. Live mode and App Review

Switch the app to Live only after development is complete.

  • Live mode can request permissions from anyone, but only permissions approved through App Review.
  • Advanced Access for pages_manage_posts, pages_manage_engagement, pages_read_engagement, pages_show_list, business_management and read_insights requires App Review and Business Verification.
  • For an app used only by its owner and role users, Standard Access in Development mode is enough; no App Review is required.
  • App Review submissions need a screencast showing the complete Facebook login, the Page selection, and a real post published as the Page. Missing or vague use-case descriptions are a common rejection reason.
  • Permissions unused for 90 days may expire and must be re-granted.

7. Testing checklist

  1. GET /v25.0/me/accounts returns the Page with a non-empty access_token.
  2. GET /v25.0/{page-id}?fields=name,fan_count works with that token.
  3. A text-only POST /{page-id}/feed succeeds.
  4. A photo post succeeds (JPEG/PNG < 4 MB).
  5. A video post succeeds only if publish_video (or the equivalent Page task) is granted.
  6. A story appears in the Page’s story tray and disappears after 24 hours.

There is no Facebook sandbox environment for Pages. Test against a real test Page and a test app in Development mode.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect facebook
rustiz channels connect facebook --open
rustiz channels connect facebook --timeout 600

The CLI prints the login URL to stderr and, when FRONTEND_URL points at this machine, serves the callback and waits for the channel to appear. With a remote FRONTEND_URL it prints the URL and exits; the server handles the callback.

The OAuth callback creates the channel with inBetweenSteps = true. The CLI has no setup command: finish the Page selection with the MCP channelSetupTool (or the corresponding web UI). Until then the channel exists but cannot publish; the scheduler refuses it with “channel … is not set up yet, finish it with channelSetupTool”.

MCP connectChannelTool

{ "provider": "facebook" }

Typical response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.facebook.com/v25.0/dialog/oauth?client_id=…&redirect_uri=…&state=…&scope=pages_show_list,business_management,pages_manage_posts,pages_manage_engagement,pages_read_engagement,read_insights",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with:

{ "connectionId": "…" }

After the callback, integrationList shows the channel with "inBetweenSteps": true.

Finishing setup: triggerTool then channelSetupTool

  1. List the Pages the token can publish to:

    {
      "integrationId": "<unfinished channel id>",
      "methodName": "pages",
      "dataSchema": []
    }
    

    facebook exposes one function:

    {
      "name": "pages",
      "description": "Pages the connected account can publish to",
      "input_schema": { "type": "object", "properties": {} }
    }
    

    The result is the same array pages() builds: [{id, username, name, access_token, picture:{data:{url}}}, …].

  2. Choose a Page and call:

    {
      "integrationId": "<unfinished channel id>",
      "data": { "page": "<page id>" }
    }
    

    channelSetupTool calls Facebook::fetch_page_information(token, {"page": "<id>"}), which re-reads /me/accounts and Business Manager, then finish_integration_setup replaces the channel’s internalId, name, picture, profile and token with the Page values and clears inBetweenSteps.

  3. Verify with integrationList: the channel name is now the Page name and inBetweenSteps is false.

There is exactly one data key for Facebook: page. Do not send {"id": …} (that is the Instagram shape) or {"pageId": …}.

What not to send

  • customFields routes to connect_custom() and treats the object as an OAuth authorization code. Never send it to facebook.
  • externalUrl is not used by Facebook; it is ignored by connect.rs (only mastodon-custom requires it).

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "url": {
      "type": "string",
      "description": "Link to attach as a preview card (image, title, domain), used only for text posts without media. A URL inside the content alone shows as plain text with no card"
    },
    "post_type": {
      "type": "string",
      "enum": ["post", "story"],
      "description": "Publish a regular page post or a story. Stories need at least one photo or video attachment, and each media becomes a separate story."
    },
    "text_format_preset_id": {
      "type": "string",
      "description": "Optional Facebook background preset for text-only posts (max ~130 characters)."
    },
    "title": {
      "type": "string",
      "description": "Title of the video, used only for video (non-story) posts"
    }
  },
  "additionalProperties": true
}
FieldTypeBehaviour
urlstringSent as link on POST /{page}/feed. Empty string means “no card”. Only meaningful for posts without media.
post_type"post" / "story"Defaults to post when absent. story switches to the story container flow.
text_format_preset_idstringSent only for text-only posts of at most 130 UTF-16 code units. If Facebook rejects the preset, the provider retries once without it.
titlestringSent as title only on a non-story video post (/videos). Ignored for photos and stories.

additionalProperties: true — unknown keys are accepted and ignored. This is intentional for forward compatibility.

validate_settings()

  • settings must be an object.
  • post_type, when non-null, must be exactly post or story.
  • text_format_preset_id, when non-null, must be a string.
  • title, when non-null, must be a string.
  • url, when non-null, must be a string. An empty string is allowed and means “no link”. A non-empty value must be a web URL: either a full http(s) URL or a scheme-less host containing a dot (the code retries with https:// prefixed). example.com/x is accepted; not a url is not.
  • No length or presence checks are performed here. Facebook itself enforces the 63,206-character limit and the 130-character preset limit.

check_validity()

Only stories are checked:

if post_type == "story" and the first post has no media:
    "Story should have at least one media"

There is no check for:

  • maximum 10 photos per feed post (Facebook’s own limit);
  • video duration/size;
  • photo dimensions or 4 MB size limit;
  • mixing photos and video in one feed post.

Those fail at the Graph API and are mapped by handle_errors().

Media requirements

MediaHow Rustiz sends itPlatform constraints
Photo (feed)POST /{page}/photos with {"url": <public URL>, "published": false}, then POST /{page}/feed with attached_media: [{media_fbid: …}]JPEG/PNG, < 4 MB per photo; up to 10 photos per post
Video (feed)POST /{page}/videos with {"file_url": <public URL>, "description", "title?", "published": true}MP4 (the code only detects .mp4), public URL; processing continues after the API returns
Photo (story)Same unpublished /photos upload, then POST /{page}/photo_stories?photo_id=…One photo per story; 24-hour lifetime
Video (story)POST /{page}/video_stories?upload_phase=start → POST upload URL with Authorization: OAuth <token> and file_url header → upload_phase=finish&video_id=…Minimum 540p; one video per story; 24-hour lifetime
Comment attachmentFirst media only, sent as attachment_urlA link attachment, not an uploaded photo
Link previewlink field on /feedMust be a public URL; Facebook scrapes Open Graph metadata

Important details:

  • Everything is URL-based. Rustiz never uploads bytes to Facebook; it gives Meta a URL and Meta fetches it. The media must be on a public HTTPS server at publish time. A Rustiz install behind a private network needs a public UPLOAD_DIRECTORY/CDN or a tunnel.
  • Stories are one story per media item. story_pending() uploads every attachment, then finalize_post() publishes exactly one item per call. A two-photo story produces two separate stories.
  • Video detection is .mp4 only. has_extension(video_path, "mp4") looks for the substring .mp4 anywhere in the path (case-insensitive). A .mov file takes the photo path and fails.
  • Feed video release URL is always a Reel URL. The video branch returns https://www.facebook.com/reel/{video_id} and ignores the permalink_url that the API returns. A normal Page video is not necessarily a Reel; use the API response if you need the canonical link.
  • Alt text is ignored. MediaContent.alt is never sent to Facebook.
  • A feed post with both link and media may be ignored or rejected. The code sends link even when attached_media is present. The schema says url is for text posts without media; treat that as the supported path.

Threads

supports_comments = true. A multi-entry post works as follows:

  1. The first entry is published as a feed post, photo post, video post or story.
  2. Each later entry is published by comment() against the previous post id (or the root post id for the first comment).
  3. comment() sends message plus, if the comment has media, the first media item as attachment_url.
  4. Comments are Page comments (POST /{post-id}/comments), so they appear under the post in Facebook’s comment UI, not as separate feed posts.

For stories, comments target the story post_id; Facebook’s story comments are limited and may not appear in all clients.

Plugs

facebook.rs has no @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported. The pages helper is a dashboard function, not a plug.

Implementation Notes & Gotchas

  • The channel is unusable until setup is finished. authenticate() stores a user token and a user id. Only channelSetupTool swaps in the Page token and Page id. Publishing before setup posts as the user and fails with “Unpublished posts must be posted to a page as the page itself”, which is mapped to a refresh error telling you to reconnect.
  • Page token lifecycle. The initial long-lived user token is valid about 60 days; expires_in is hard-coded to 59 * 24 * 3600. refresh_token() is a no-op (returns an empty access token with expires_in: 0), and refresh_cron is false. There is no proactive refresh. When the token expires, publishing gets a 190/Error validating access token, the engine marks the channel refreshNeeded, and the user must reconnect. Page tokens derived from a long-lived user token can outlive the user token for Page actions, but Rustiz still gates on the stored expiration.
  • Reconnect page-id mismatch. save() with ?refresh=<id> requires the re-authenticated AuthTokenDetails.id to equal the stored integration.internal_id. For a Page channel that stored id is the Page id, while authenticate() returns /me’s user id. The comparison fails with “Please refresh the channel that needs to be refreshed”. The provider’s reconnect() (which re-derives the Page token) is only called by the engine when root_internal_id is set, and Facebook channels have no root. Practically: reconnect by creating a new connection, then delete the old channel.
  • refresh_token is not a refresh token. authenticate() stores the long-lived access token in both access_token and refresh_token. Nothing rotates it. Do not treat it as a Meta fb_exchange_token input.
  • Long-lived exchange has no redirect URI. The second token call is GET /oauth/access_token?grant_type=fb_exchange_token&client_id=…&client_secret=…&fb_exchange_token=…&fields=access_token,expires_in. Meta ignores fields here; the code reads access_token only.
  • Graph version is pinned to v25.0. Meta supports each Graph version for roughly two years. Check the App Dashboard’s “API version” notice before a long-lived deployment; upgrading requires editing the constant.
  • Business Manager failures are swallowed. pages() and fetch_page_information() ignore errors from /me/businesses, owned_pages and client_pages and continue with /me/accounts. A permission error on Business Manager therefore shows up as “Page not found in your accounts”, not as a Graph error.
  • found_without_token matters. A Page listed through a business the user has no role on comes back without access_token. The provider detects this and returns a specific message instead of a generic “not found”.
  • max_concurrent_job = 500 is optimistic. Meta rate limits are per app/user pair and Pages have their own publishing limits. A 500-job burst can hit (#32) Page request limit or 1366046/1390008 throttling. The code maps 1390008 to a retry (“You are posting too fast, please slow down”).
  • Feed posts are not pending. post_pending() for post_type != story calls post_non_story(), which performs the feed call and returns PostStatus::Success. supports_pending = true exists only for the story flow. The engine’s post() compatibility path still loops on the story pending data; ordinary posts return immediately.
  • Story handshake. Story publishing uses attempting (the item index) and confirmed. check_post_status() waits for every not-yet-published video to report upload_complete or ready, then witnesses the armed item. If a run dies after the confirm, the provider refuses to retry with “Facebook may have already published part of the story” rather than duplicate a story.
  • One story per call. finalize_post() publishes items[published_count], increments the counter and returns Pending if more items remain. Each story counts as a separate publication and has its own post_id.
  • Video processing is polled by GET /{video_id}?fields=status. Only upload_complete and ready count as done; error becomes “Video processing failed”. There is no byte-level upload progress because the file is fetched by Meta from a URL.
  • Comment media is a URL attachment. attachment_url makes Facebook scrape the URL as a link preview. It does not upload the image as a comment photo. A comment with a video URL is unlikely to render as a video.
  • Analytics swallow failures. analytics() logs and returns an error only when the insights response contains an error object; post_analytics() catches feed errors and returns []. Video insights are used whenever the post id has no underscore:
    • feed post id {pageid}_{postid} → /insights;
    • Reel/video id (bare numeric) → /video_insights. A Page post id that happens to lack an underscore is misclassified as video.
  • Percentage change is fake. Page analytics hard-code percentage_change: 5.0; post analytics hard-code 0.0. Do not present these as real period-over-period changes.
  • Insight metric names are version-sensitive. page_total_media_view_unique and page_media_view have been renamed/deprecated by Meta over time. If the insights call returns an error, the whole analytics request fails (page analytics) or returns empty (post analytics).
  • text_format_preset_id retry. is_preset_rejection() treats an error as a preset rejection unless the detail mentions access token, re-authenticate, revoked, code 190, or the message is not “Unknown Error”. It then retries once without the preset. This can mask a real error as a successful plain post.
  • Preset length is UTF-16. message.encode_utf16().count() <= 130 matches JavaScript string length, not Unicode scalar count. An emoji counts as 2.
  • Error mapping. Dozens of Meta subcodes are translated: 1366046 photos

    4 MB / not JPG-PNG, 1390008 posting too fast, 1346003 abusive content, 1404006 security check, 1404078 Page publishing authorization, 1366051 duplicate photos, 1609008 Facebook.com links, 4854002 identity confirmation, subcode 459 security check, subcode 492 lost Page role, subcode 33 deleted Page/post. A bare 401 is treated as an unknown error, not a refresh.

  • pages() exposes Page access tokens. The pages function returns the raw access_token for every Page, and triggerTool sends that array to the MCP client. Treat the tool output as secret; do not log it or paste it into shared transcripts. The channelSetupTool response itself only returns {success, name}.
  • Rate limits. Meta Graph API limits are per app/user pair and depend on Page impressions; Pages also have daily posting limits. max_concurrent_job = 500 should be lowered for media-heavy workloads. The provider does not read X-Business-Use-Case-Usage or X-App-Usage headers to back off.
  • No sandbox. There is no Facebook Pages sandbox. Use a test Page, a test app in Development mode, and role users. Switching the app to Live exposes Development-mode content to everyone.

References

Farcaster / Warpcast (wrapcast)

Farcaster is a decentralized social protocol; Warpcast is its best-known client. Rustiz publishes through Neynar, which manages the Farcaster signer. The connect flow is not OAuth: the server creates a Neynar signer, signs a SignedKeyRequest with the app’s custody mnemonic, shows the human an approval link/QR code, and polls until the signer is approved in the Farcaster app.

  • Rust source: crates/rustiz-providers/src/farcaster.rs
  • Upstream port: farcaster.provider.ts
  • Editor: Normal
  • Connection mode: signer
  • Identifier is wrapcast, not farcaster.

Overview

PropertyValue
Identifierwrapcast
Display nameFarcaster
Connection modesigner (server-created Neynar signer, human approval)
Editornormal
Channel kindsocial
Comments / threadsAdvertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”.
Pending / async publishingNo. supports_pending = false; post() calls Neynar and returns Posted
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)800 characters, hard-coded
Web3 flagis_web3 = true
ScopesNone
Redirect URINone; the approval URL is a farcaster:// deep link

Server Environment Variables

required_env() returns four variables:

#![allow(unused)]
fn main() {
&[
    "NEYNAR_CLIENT_ID",
    "NEYNAR_APP_FID",
    "NEYNAR_APP_MNEMONIC",
    "NEYNAR_SECRET_KEY",
]
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
NEYNAR_CLIENT_IDYes—Neynar app client id. Only used by the legacy generate_auth_url() (client id, two vertical bars, state) and the readiness check; the headless signer flow does not use it.
NEYNAR_APP_FIDYes—Farcaster FID of the app account whose custody mnemonic signs the signer request. Parsed as i64; a non-number is a configuration error.
NEYNAR_APP_MNEMONICYes—BIP-39 mnemonic of the app account’s custody address. Signs the EIP-712 SignedKeyRequest. Secret.
NEYNAR_SECRET_KEYYes—Neynar API key, sent in the api_key header by the Rust code
NEYNAR_BASE_URLNohttps://api.neynar.comNeynar API origin; trailing slash trimmed
NEYNAR_SPONSOR_SIGNERSNounsetWhen exactly "true", adds sponsor: {sponsored_by_neynar: true} to the signed-key registration. Neynar charges the developer’s credits.
JWT_SECRETNo direct useemptyNot read by the Farcaster flow. The signer uuid is stored as the integration token; the pending {signerUuid, approvalUrl} row is plaintext. Listed only because the rest of the server needs it.

rustiz providers --status requires all four variables and reports wrapcast as missing until they are set. NEYNAR_CLIENT_ID is required by that check even though the headless flow does not use it; set any non-empty value if your Neynar app does not expose one (for example, the client id shown in the dashboard).

The fallback API key inside api_key() is the placeholder 00000000-000-0000-000-000000000000. It is only used when NEYNAR_SECRET_KEY is unset, which provider_status() normally prevents.

Step-by-Step Developer App Setup

1. Create a Neynar account and app

  1. Sign in at https://dev.neynar.com.
  2. Create an app. Neynar returns a Client ID and a Secret Key. The secret key is the API key used for every /v2/farcaster/... call.
  3. Note the app’s FID and the custody mnemonic of the Farcaster account used as the app. Neynar’s app settings expose the FID; the mnemonic is the seed phrase of that FID’s custody address. If the FID was created in another wallet, use that wallet’s mnemonic instead. The mnemonic is used only to sign signer requests; it is not a user mnemonic.
  4. Put the values in the environment:
NEYNAR_CLIENT_ID="…"
NEYNAR_APP_FID="12345"
NEYNAR_APP_MNEMONIC="word word word …"
NEYNAR_SECRET_KEY="…"
  1. Restart rustiz serve, rustiz worker and rustiz mcp; they read the configuration at startup.

2. What the app FID and mnemonic are for

Farcaster requires an app to prove it controls an FID before a signer can be registered for a user. Rustiz derives the app’s Ethereum key from the mnemonic at m/44'/60'/0'/0/0 (BIP-39 seed, empty passphrase, BIP-32) and signs the EIP-712 payload:

Domain:
  name:              "Farcaster SignedKeyRequestValidator"
  version:           "1"
  chainId:           10
  verifyingContract: 0x00000000FC700472606eD4Fa22623AcF62c60553

Type:
  SignedKeyRequest(uint256 requestFid, bytes key, uint256 deadline)

The signature is 65 bytes (r, s, v+27), 0x-prefixed. If the mnemonic is wrong, Neynar rejects the signed-key registration with a 4xx; the connect call fails before a human sees an approval link.

The implementation is a small self-contained BIP-39/BIP-32 + secp256k1 + Keccak-256 signer; it does not use viem or an Ethereum SDK.

3. Redirect URI

There is no redirect URI. start_farcaster() in crates/rustiz-server/src/connect.rs never calls generate_auth_url(). The human approval is a deep link into the Farcaster app:

farcaster://signed-key-request?token=0x…

The provider also renders the link as a PNG QR data URL. No domain, no FRONTEND_URL callback and no redirectmeto.com are involved.

4. Scopes

Farcaster/Neynar does not use OAuth scopes here. info.scopes is empty. check_scopes() is never called. Access is controlled by the Neynar API key and the approved signer’s permissions.

5. Sandbox / testing vs production

There is no Farcaster “sandbox” and no app review. The gates are:

  • Signer approval. Every user must approve the signer once in the Farcaster app. That is an onchain transaction; Neynar charges for it (or the developer sponsors it with NEYNAR_SPONSOR_SIGNERS=true and pays in credits). Neynar’s own documentation warns: “While testing please reuse the signer, it costs money to approve a signer.”
  • Neynar plan/credits. Read and write endpoints are metered by the Neynar plan. A free/dev plan is enough to test with a few accounts; production volume needs a paid plan.
  • App FID. A production app should use a dedicated Farcaster account/FID for the app, not a personal account, because the custody mnemonic signs every signer request.

For tests:

  1. Connect once with a test Farcaster account and keep the resulting channel.
  2. Do not delete/reconnect repeatedly: each connect attempt creates a new signer and can cost another approval.
  3. If you already have an approved Neynar signer, skip signer creation with customFields.signerUuid (see below).

Connecting a Channel (CLI & MCP)

Flow A — create a new signer (normal)

CLI

rustiz channels connect wrapcast

The command prints a JSON document (stdout) with:

{
  "connected": false,
  "pending": true,
  "connectionId": "fc_…",
  "approvalUrl": "farcaster://signed-key-request?token=0x…",
  "qrCode": "data:image/png;base64,…",
  "message": "Give this link (or QR code) to the human: …"
}

The CLI does not wait for the approval, because there is no browser callback. Give the approvalUrl or qrCode to the human, then poll:

rustiz channels connect-status fc_…

The connection id lives for 30 minutes. Polling does not extend the TTL; if it expires, start again (and pay for a new signer).

MCP connectChannelTool

{ "provider": "wrapcast" }

Returns the same connectionId, approvalUrl and qrCode. Poll with:

{ "connectionId": "fc_…" }

integrationSchema reports:

{
  "type": "signer",
  "headless": true,
  "requiresHuman": true,
  "requires": [
    "NEYNAR_CLIENT_ID",
    "NEYNAR_APP_FID",
    "NEYNAR_APP_MNEMONIC",
    "NEYNAR_SECRET_KEY"
  ]
}

Flow B — reuse an approved signer (customFields.signerUuid)

If a Neynar signer was already created and approved (for example, by another Neynar app using the same API key), pass its uuid:

{
  "provider": "wrapcast",
  "customFields": {
    "signerUuid": "19d0c5fd-9b33-4a48-a0e2-bc7b0555baec"
  }
}

CLI:

printf '%s' '{"signerUuid":"19d0c5fd-9b33-4a48-a0e2-bc7b0555baec"}' \
  | rustiz channels connect wrapcast --fields -

The server:

  1. Requires NEYNAR_SECRET_KEY.
  2. Calls GET /v2/farcaster/signer?signer_uuid=….
  3. If the status is approved and the response has an fid, builds the same base64 user payload and connects immediately.
  4. If the signer is not approved, returns an actionable error such as That Farcaster signer is not approved yet (status: pending_approval). Open its approval link in the Farcaster app first.

This path only checks NEYNAR_SECRET_KEY. It does not need NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC or NEYNAR_CLIENT_ID at runtime. The normal signer-creation path calls provider_status() and requires all four, so in practice set all four.

Signer approval details

The server’s sequence (Farcaster::create_signer()):

  1. POST /v2/farcaster/signer with {} → signer_uuid, public_key, status: generated.
  2. deadline = now + 24 hours.
  3. Sign the EIP-712 SignedKeyRequest with the app mnemonic.
  4. POST /v2/farcaster/signer/signed_key with:
{
  "signer_uuid": "…",
  "app_fid": 12345,
  "deadline": 1760000000,
  "signature": "0x…",
  "sponsor": { "sponsored_by_neynar": true }
}

sponsor is present only when NEYNAR_SPONSOR_SIGNERS=true.

  1. Read signer_approval_url and signer_uuid; build qrCode = qr_data_url(approval_url) (PNG data URL, 4 px modules, 4-module margin).
  2. Store {signerUuid, approvalUrl} in the oauth_states row as code_verifier, provider="wrapcast", TTL 30 minutes.

When the human approves in the Farcaster app, the server polls:

  1. GET /v2/farcaster/signer?signer_uuid=….
  2. If status != "approved", return pending (or an error if revoked).
  3. If approved, read fid.
  4. GET /v2/farcaster/user/bulk?fids=<fid> to get username, display_name, pfp_url.
  5. Build a base64 JSON payload:
{
  "signer_uuid": "…",
  "fid": 12345,
  "username": "alice",
  "display_name": "Alice",
  "pfp_url": "https://…"
}
  1. authenticate() decodes it and stores:
    • id = FID
    • access_token = signer_uuid
    • refresh_token = ""
    • expires_in = 200 years
    • username = Farcaster username
    • name = display name
    • picture = pfp URL

The signer uuid is the credential used for every cast. There is no token refresh.

Do not send customFields without signerUuid

connect.rs treats any customFields object as the credential path. For wrapcast, the object must contain signerUuid (or signer_uuid); otherwise the flow returns Farcaster customFields needs signerUuid of an already-approved Neynar signer.

Post Settings & Media Validation

settings_schema()

The field is named subreddit for historical reasons (copied from Reddit), but it holds Farcaster channels:

{
  "type": "object",
  "properties": {
    "subreddit": {
      "type": "array",
      "description": "Farcaster channels to cast into (from the subreddits tool). Leave empty to cast on the user's timeline.",
      "items": {
        "type": "object",
        "properties": {
          "value": {
            "type": "object",
            "properties": {
              "id": { "type": "string", "description": "Channel id." }
            },
            "required": ["id"],
            "additionalProperties": true
          }
        },
        "required": ["value"],
        "additionalProperties": true
      }
    }
  },
  "additionalProperties": true
}

There is no required at the top level. subreddit may be omitted, null or an empty array; all three mean “post to the user’s timeline”.

validate_settings() rules

  1. If subreddit is present and not null, it must be an array.
  2. Each entry must be an object.
  3. Each entry must have a value that is an object.
  4. value.id must be a string.
  5. Extra properties are allowed at every level.

It does not check that the channel exists, that the id is non-empty, or that the user can post in it. Neynar returns the error at publish time.

check_validity()

Only one rule:

#![allow(unused)]
fn main() {
if posts.iter().any(|items| {
    items.iter().any(|item| item.path.contains("mp4"))
}) {
    return Ok(Some("Can only accept images".into()));
}
}
  • Any media path containing mp4 (case-sensitive) rejects the whole post.
  • No check on image count, size, format or URL reachability.
  • The check runs for every entry in the thread, even though threads are effectively disabled (supports_comments = false).

Media requirements

Farcaster casts support up to 2 embeds, each a URL up to 256 bytes. Rustiz does not upload media: it maps every MediaContent.path to:

{ "url": "https://cdn.example/image.png" }

and sends them in embeds to POST /v2/farcaster/cast. Consequences:

  • The URL must already be publicly reachable; a local file path will not work.
  • Rustiz does not cap embeds at 2. Three or more media items produces a request Neynar/Farcaster rejects.
  • Alt text, thumbnails and video are ignored.
  • Images are the only supported type; check_validity() blocks .mp4, but a .MP4 (uppercase) bypasses it and is sent as a URL embed.
  • A cast can have text plus embeds; an empty text with embeds may be accepted, but Rustiz always sends text (possibly empty).

Channels

  • cast_ids() reads posts.first().settings["subreddit"].
  • null, a non-array, or an empty array → one cast with no channel_id (timeline).
  • A non-empty array → one cast per value.id, each with channel_id.
  • post_id and release_url are comma-joined across channels. The engine stores the whole string; comment() splits it on commas to reply to every cast.
  • If one channel cast fails, the whole post() returns an error. Casts already published to earlier channels remain live; there is no rollback.
  • The same applies to replies: comment() sends one reply per parent id and stops at the first failure.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

functions() declares one function:

FunctionParamsReturnsNotes
subreddits{ "word": "search term" }[{"title":"…","name":"…","id":"…"}]GET /v2/farcaster/channel/search?q=<word>&limit=10, maps Neynar channels

MCP:

{
  "integrationId": "int_…",
  "methodName": "subreddits",
  "dataSchema": [{ "key": "word", "value": "developers" }]
}

Use the returned id as subreddit[].value.id. There is no channelSetupTool: is_between_steps is false, so the channel is selected per post, not at connect time.

Implementation Notes & Gotchas

  • Threads are implemented but not advertised. comment() is a full implementation (reply to each parent cast with optional embeds), yet info.supports_comments is never set to true. The engine truncates a multi-entry post to the first entry, and MCP validatePostTool reports “Farcaster does not support thread replies”. If you need threads, set info.supports_comments = true in Farcaster::new(); the code path already exists.
  • post() only reads the first PostDetails. Even if called directly with several entries, it publishes one cast per configured channel for the first entry and ignores the rest. The engine truncates the chain first because supports_comments = false.
  • NEYNAR_CLIENT_ID is dead weight in the headless flow. It is required by provider_status() and only used by generate_auth_url(), which returns "{client_id}||{state}" — not a URL and never called by connect.rs. Set it, but do not expect it to affect signer creation.
  • Neynar header mismatch. The Rust code sends the API key as api_key. Current Neynar OpenAPI documents the security scheme as x-api-key. Neynar has historically accepted api_key; if every call returns 401, change the header to x-api-key in neynar_get() / neynar_post().
  • Trailing slashes. The code uses /v2/farcaster/signer and /v2/farcaster/signer/signed_key without a trailing slash. Neynar’s docs show /v2/farcaster/signer/ and /v2/farcaster/signer/signed_key/. Most gateways normalize this; a 404 on signer creation is the symptom.
  • Signer creation is costly and not deduplicated. Every connect attempt calls POST /v2/farcaster/signer and registers a new signed key. There is no lookup of an existing signer before creating one. Reconnecting a channel therefore costs another approval unless you use customFields.signerUuid. Neynar explicitly warns to reuse signers while testing.
  • Approval TTL is 30 minutes and is not refreshed. poll_farcaster() does not call put_pending() again, unlike Telegram. If the human misses the window, the state row is gone and a retry starts a new signer.
  • create_signer() does not validate Neynar’s response. It reads signer_uuid and signer_approval_url with unwrap_or_default(). A malformed/partial response produces a pending connection with an empty approval URL (and a QR code for an empty string) or an empty signer uuid that can never be polled. Check the raw Neynar response if the first connect call looks wrong.
  • signer_status() can get stuck. It only returns an approval code when status == "approved" and the response contains fid. If Neynar reports approved without fid (rare, e.g. partial indexing), the server keeps returning {status:"approved", code:null} and the connect poll never completes. Restarting the connection is the only recovery.
  • Revoked signers. poll_farcaster() detects status == "revoked", deletes the pending state and returns an actionable error. The customFields shortcut does not special-case revoked; it says “not approved yet”.
  • Rate limits are plan-based. Neynar’s published tiers are roughly: Free (limited), Starter 300 RPM / 5 RPS per endpoint with a 500 RPM global cap, Growth 600 RPM / 10 RPS with 1,000 RPM global, Scale 1,200 RPM / 20 RPS with 2,000 RPM global. max_concurrent_job = 3 is conservative but does not reserve credits. Signer writes and active signers consume credits on top of request limits.
  • No signer refresh. refresh_token() returns an empty token with expires_in: 0; the signer uuid is the long-lived credential. If the user revokes the signer in the Farcaster app, the next cast fails with Neynar’s error and the channel must be reconnected with a new signer (or a different approved signerUuid).
  • No idempotency. POST /v2/farcaster/cast is called without Neynar’s idem field. A retry after a timeout can double-cast. The engine’s retry policy is the only protection.
  • 800 characters is a Postiz limit, not a protocol limit. Farcaster casts are limited by bytes (historically 320 bytes, now 1,024 bytes for standard casts), not by a flat 800 characters. The engine counts chars(); a cast of 800 emoji can exceed the byte limit, and a cast of 800 ASCII characters may be fine. There is no byte check.
  • No byte/embed validation. Each embed max 256 bytes and max 2 embeds are Farcaster rules; Rustiz checks neither. A long image URL or a third image fails at Neynar.
  • Media is not uploaded. embeds are URLs, so the media must be hosted publicly before publishing. This differs from Bluesky/Mastodon, where Rustiz uploads bytes.
  • subreddits is a search, not a list. It returns at most 10 channels for the query. There is no “my channels” call. If the user does not know the channel name, they must search for it.
  • is_web3 = true. connect.rs special-cases wrapcast before the generic web3 rejection, so it is supported. Other web3 providers are not.
  • No analytics / mentions. supports_analytics, supports_post_analytics and supports_mentions are all false; there are no overrides.
  • Release URL. cast_urls() builds https://warpcast.com/{author username}/{cast hash} from the Neynar response. If the response omits the author username, the URL contains //; the hash is still the post id.
  • Multiple channels produce comma-joined ids. Any consumer that expects a single cast hash must split on commas. The engine’s comment() already does.

References

Google My Business / Google Business Profile (gmb)

gmb publishes a local post to one Google Business Profile location through the legacy Google My Business API v4 accounts.locations.localPosts endpoint, and reads location-level performance metrics through the Business Profile Performance API. Accounts and locations are listed through the v1 My Business Account Management and My Business Business Information APIs.

The provider is is_between_steps = true: OAuth authenticates a Google account, then channelSetupTool selects one business location (an account can own many). Publishing is synchronous — Google either returns the new local post name or rejects it; there is no media-polling phase.

  • Rust source: crates/rustiz-providers/src/gmb.rs
  • Upstream port: gmb.provider.ts
  • Editor: Normal
  • Connection mode: oauth + a mandatory location-selection step

Two independent gates. OAuth consent (Testing/Production) is one; the Google Business Profile API access request is the other. Even a perfectly configured OAuth client gets 0 QPM and PERMISSION_DENIED until Google approves the Cloud project for the Business Profile APIs. Approval requires a verified Business Profile active for 60+ days and a website.

Overview

PropertyValue
Identifiergmb
Display nameGoogle My Business
Connection modeoauth, then channelSetupTool (location selection)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingNo. supports_pending = false; localPosts.create is synchronous
AnalyticsYes. supports_analytics = true; location-level Performance API metrics
Post analyticsFlag is true, implementation always returns []. Local posts have no per-post metrics; the API is location-level
MentionsNo. supports_mentions = false
is_between_stepstrue
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)1500 characters (GBP local-post summary limit)
APIMy Business API v4 (mybusiness.googleapis.com), Account Management v1, Business Information v1, Business Profile Performance v1
OAuth scopeshttps://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../business.manage
Token lifetimeGoogle access token ~1 h; refresh token until revoked (7 days in Testing mode)

Server Environment Variables

required_env() returns four names because the provider accepts two credential families:

#![allow(unused)]
fn main() {
&[
    "GOOGLE_GMB_CLIENT_ID",
    "GOOGLE_GMB_CLIENT_SECRET",
    "YOUTUBE_CLIENT_ID",
    "YOUTUBE_CLIENT_SECRET",
]
}

required_env_alternatives() collapses each pair:

#![allow(unused)]
fn main() {
&[
    &["GOOGLE_GMB_CLIENT_ID", "YOUTUBE_CLIENT_ID"],
    &["GOOGLE_GMB_CLIENT_SECRET", "YOUTUBE_CLIENT_SECRET"],
]
}

So rustiz providers --status reports gmb as configured when either pair is present. The runtime fallback is per field:

#![allow(unused)]
fn main() {
client_id     = GOOGLE_GMB_CLIENT_ID     or YOUTUBE_CLIENT_ID
client_secret = GOOGLE_GMB_CLIENT_SECRET or YOUTUBE_CLIENT_SECRET
}
VariableRequiredDefaultPurpose
GOOGLE_GMB_CLIENT_IDOne of the pairnonePreferred GMB OAuth client id.
GOOGLE_GMB_CLIENT_SECRETOne of the pairnonePreferred GMB OAuth client secret.
YOUTUBE_CLIENT_IDOne of the pairnoneFallback client id (shared Google app).
YOUTUBE_CLIENT_SECRETOne of the pairnoneFallback client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI. Trailing / stripped.
GOOGLE_AUTH_BASE_URLNohttps://accounts.google.com/o/oauth2/v2/auth.
GOOGLE_OAUTH_BASE_URLNohttps://oauth2.googleapis.com/token, /tokeninfo.
GOOGLE_BASE_URLNohttps://www.googleapis.com/oauth2/v2/userinfo.
GMB_ACCOUNTS_BASE_URLNohttps://mybusinessaccountmanagement.googleapis.com/v1/accounts.
GMB_INFO_BASE_URLNohttps://mybusinessbusinessinformation.googleapis.com/v1/{account}/locations, /v1/{location}, /v1/{location}/media.
GMB_BASE_URLNohttps://mybusiness.googleapis.comv4 local posts: /v4/{id}/localPosts, where {id} is the full accounts/<n>/locations/<m> resource name.
GMB_PERFORMANCE_BASE_URLNohttps://businessprofileperformance.googleapis.com/v1/locations/{id}:fetchMultiDailyMetricsTimeSeries.
GMB_WEB_BASE_URLNohttps://business.google.comFallback release URL /locations/{locationId}.

Using the YouTube credentials is fully supported: the app and redirect URIs are yours to share. Using separate GMB credentials is cleaner when the GMB app must be verified independently.

rustiz providers --status | jq '.[] | select(.identifier=="gmb")'

Step-by-Step Developer App Setup

1. Prepare the Business Profile

  • You need a Google account that owns or manages a Google Business Profile.
  • The profile must be verified and active for at least 60 days.
  • The business must have a website listed on the profile.
  • For multi-location clients, the same account can manage many locations; the provider lists all of them and you select one.

2. Create the Google Cloud project and request API access

  1. Open https://console.cloud.google.com/ → Create project.
  2. Copy the Project number from the project dashboard.
  3. Submit Application for Basic API Access: https://support.google.com/business/contact/api_default → choose Application for Basic API Access. Provide the project number and a contact email that is an owner/manager on the Business Profile.
  4. Wait for the approval email. To check without waiting: open APIs & Services → Quotas for the Business Profile APIs.
    • 0 QPM → not approved yet;
    • 300 QPM → approved.
  5. Approval can take a few days to a week. Do not file a quota-increase request while the quota is 0; that is not the right form.

3. Enable the APIs

Under APIs & Services → Enable APIs and Services:

APIUsed for
Google My Business API (v4)localPosts.create — only appears in the library after the access request is approved
My Business Account Management APIGET /v1/accounts
My Business Business Information APIGET /v1/{account}/locations, GET /v1/{location}, GET /v1/{location}/media
Business Profile Performance APIfetchMultiDailyMetricsTimeSeries analytics

The Postiz GMB guide lists only the first three. Enable the Performance API too, otherwise the Analytics tab silently returns no data (the provider catches the error).

Google Auth Platform → Audience:

  1. User type External (or Internal for a Workspace-only deployment).
  2. App name, support email, developer contact, logo, authorized domains.
  3. Add the scopes .../auth/userinfo.profile, .../auth/userinfo.email, .../auth/business.manage.
  4. Add every Google account that will connect as a test user while in Testing.
  5. Publish the app for production. business.manage is a sensitive Business Profile scope and will require OAuth verification (privacy policy, homepage, demo video, scope justification).

5. Create the OAuth client

  1. Google Auth Platform → Clients → Create client.

  2. Type Web application.

  3. Authorized redirect URIs:

    {FRONTEND_URL}/integrations/social/gmb
    

    Example https://social.example.com/integrations/social/gmb. http://localhost:5000/integrations/social/gmb is allowed for local testing. No trailing slash beyond what FRONTEND_URL produces (it is trimmed).

  4. Copy the Client ID and Client secret.

6. Configure Rustiz

Either the dedicated pair:

GOOGLE_GMB_CLIENT_ID="....apps.googleusercontent.com"
GOOGLE_GMB_CLIENT_SECRET="..."
FRONTEND_URL="https://social.example.com"

or reuse the YouTube app (and add the GMB redirect URI to it):

YOUTUBE_CLIENT_ID="....apps.googleusercontent.com"
YOUTUBE_CLIENT_SECRET="..."
FRONTEND_URL="https://social.example.com"

Restart, check rustiz providers --status, then connect and pick a location.

Exact OAuth scopes and checks

#![allow(unused)]
fn main() {
vec![
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/business.manage",
]
}

business.manage is the only Business Profile scope. It grants read/write access to every location the authenticated account manages — there is no per-location scope.

After the code exchange, token_scopes() calls GET {GOOGLE_OAUTH_BASE_URL}/tokeninfo?access_token=<token> and accepts either a scopes array or a scope string. check_scopes requires all three. A user who unchecks business.manage on the granular consent screen is refused with not_enough_scopes.

The authorization URL is built with access_type=offline and prompt=consent:

{GOOGLE_AUTH_BASE_URL}/o/oauth2/v2/auth
  ?client_id=...&redirect_uri=...&response_type=code
  &scope=...userinfo.profile ...userinfo.email ...business.manage
  &state=<7 chars>&prompt=consent&access_type=offline

Redirect URI details

{FRONTEND_URL}/integrations/social/gmb
  • Config::frontend_url() strips trailing slashes; the path is appended once.
  • No redirectmeto.com; the literal value is sent at authorize time and again at token exchange. Google requires an exact match (scheme, host, port, path).
  • Localhost HTTP is allowed by Google for development; production must be HTTPS.

Sandbox / testing mode

  • The OAuth consent screen’s Testing status is the only “sandbox”. It allows up to 100 test users and shows the unverified-app warning.
  • Test-mode authorizations and refresh tokens expire after 7 days. A location connected in Testing mode stops working a week later.
  • The Business Profile API access approval is independent of Testing mode: without it, every Business Profile call returns PERMISSION_DENIED / 0 QPM. You cannot bypass it by adding test users.
  • Use a real location you manage. Local posts are public on Google Search/Maps as soon as they are created; the API has no draft state. If you do not want test content visible, use a secondary/test location or delete the post after the test.
  • The legacy v4 localPosts endpoint still works in 2026 even though accounts, locations and performance moved to v1. The provider depends on it.

Production app review

  1. Publish the OAuth app and complete OAuth verification for business.manage (sensitive scope). Google wants a homepage, privacy policy, demo video and a clear explanation of why the app needs Business Profile access.
  2. Business Profile API access must already be approved (300 QPM). The approval is tied to the Cloud project, not the OAuth client.
  3. Quotas after approval (defaults):
    • Account Management API: 300 QPM;
    • Business Information API: 300 QPM;
    • Performance API: 300 QPM;
    • location edits: 10 per minute per profile, not increasable. Exceeding a quota returns HTTP 429 (RESOURCE_EXHAUSTED for gRPC-style clients), which the provider maps to a “Rate limit exceeded” BadBody.
  4. business.manage can be revoked by the user at any time at https://myaccount.google.com/permissions; the next call fails with UNAUTHENTICATED/invalid_grant and the channel is marked for reconnect.

Connecting a Channel (CLI & MCP)

CLI

# 1. OAuth login. Creates an in-between-steps placeholder channel.
rustiz channels connect gmb --open

# Remote server: poll the login.
rustiz channels connect-status <connectionId>

# 2. No CLI location-selection command; finish with MCP channelSetupTool.
rustiz channels list

Do not pass --fields (custom-credentials branch) or --external-url (unsupported).

The CLI’s in-process callback server only runs when FRONTEND_URL is http://localhost/http://127.0.0.1; otherwise rustiz serve handles the callback.

MCP

Step 1 — start OAuth:

{ "provider": "gmb" }

Step 2 — poll:

{ "connectionId": "abc123" }

The channel exists with inBetweenSteps = true; its internal_id is the Google account id and its name/picture are the Google account’s, not a location’s.

Step 3 — list accounts and locations:

{
  "integrationId": "int_...",
  "methodName": "pages",
  "dataSchema": []
}

pages performs, in order:

GET {GMB_ACCOUNTS_BASE_URL}/v1/accounts[?pageToken=...]
GET {GMB_INFO_BASE_URL}/v1/{accountName}/locations?readMask=name,title,storefrontAddress,metadata[&pageToken=...]
GET {GMB_INFO_BASE_URL}/v1/{locationName}/media

and returns one object per location:

{
  "id": "accounts/123/locations/456",
  "name": "Downtown Shop",
  "picture": { "data": { "url": "https://lh3.googleusercontent.com/..." } },
  "accountName": "accounts/123",
  "locationName": "locations/456"
}

picture is the PROFILE media item, or the first media item, or "". The media request is per location (N+1); failures are swallowed and produce an empty picture.

Step 4 — select the location:

{
  "integrationId": "int_...",
  "data": {
    "id": "accounts/123/locations/456",
    "accountName": "accounts/123",
    "locationName": "locations/456"
  }
}

All three fields are needed:

  • locationName is required by fetch_page_information (missing → "locationName is required to fetch the location");
  • id becomes the channel’s internal_id and is the {id} in /v4/{id}/localPosts;
  • accountName is accepted for symmetry but not used by the fetch call.

fetch_page_information calls GET /v1/{locationName}?readMask=name,title,storefrontAddress,metadata, then the media endpoint for the picture, and returns id (from the data), title as the name, and the picture. channelSetupTool writes it all in, clears inBetweenSteps, and returns { "success": true, "name": "Downtown Shop" }.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
pagesnonearray of {id, name, picture, accountName, locationName}Paginates accounts and locations; a failing account’s location list is skipped silently.

Post Settings & Media Validation

settings_schema()

All fields are optional; there is no required array.

FieldTypeBehavior
topicTypeenum STANDARD, EVENT, OFFERDefaults to STANDARD at publish time when missing/empty.
callToActionTypeenum NONE, BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, GET_OFFER, CALLCTA button. NONE disables it. The CTA is only sent when both a type and a non-empty callToActionUrl are present — see the CALL gotcha.
callToActionUrlstringURL opened by the CTA. Validated as an absolute http(s) URL with a host when non-empty.
eventTitlestringRequired for EVENT.
eventStartDatestring YYYY-MM-DDEvent start; invalid/missing → today (local date).
eventEndDatestring YYYY-MM-DDEvent end; invalid/missing → today.
eventStartTimestring HH:MMOptional; parsed into {hours, minutes, seconds: 0, nanos: 0}.
eventEndTimestring HH:MMOptional.
offerCouponCodestringOffer coupon code.
offerRedeemUrlstringOffer redemption URL; validated as an absolute http(s) URL when non-empty.
offerTermsstringOffer terms and conditions.

additionalProperties: true. There is no visibility, language, topic/category or scheduling field; the post’s message becomes summary.

validate_settings() rules

  • topicType, if present and non-null, must be a string in STANDARD/EVENT/OFFER.
  • If topicType == "EVENT", eventTitle must be a JSON string (an empty string passes this check; check_validity catches it later).
  • callToActionType, if present, must be one of the eight enum values.
  • callToActionUrl and offerRedeemUrl, when present and non-empty, must parse as http/https with a host. Empty strings are allowed (they simply disable the field).
  • Everything else is passed through unvalidated.

check_validity() rules

  • More than one media item → "Google My Business posts can only have one image".
  • Any media path containing mp4 → "Google My Business posts do not support video attachments".
  • topicType == "EVENT" with an empty/missing eventTitle → "Event posts require an event title".
  • Zero media is allowed (text-only post).

Media and publish behavior

  • One image, no video. The post body still has a media array with one item: {"mediaFormat": "PHOTO"|"VIDEO", "sourceUrl": <path>}. Validation only rejects paths containing mp4, so a .mov/.webm video path would pass and be sent as VIDEO; Google does not accept video local posts, so keep media to JPEG/PNG. sourceUrl must be a public HTTPS URL that Google can fetch; a local file path will not work.

  • The image is referenced, not uploaded: GBP pulls it from the URL. There is no size validation in the provider.

  • Synchronous create:

    POST {GMB_BASE_URL}/v4/{id}/localPosts
    Authorization: Bearer <token>
    Content-Type: application/json
    

    where {id} is the full accounts/<n>/locations/<m> name.

    Body:

    {
      "languageCode": "en",
      "summary": "<the post message>",
      "topicType": "STANDARD",
      "callToAction": { "actionType": "LEARN_MORE", "url": "https://..." },
      "media": [{ "mediaFormat": "PHOTO", "sourceUrl": "https://cdn.example/a.jpg" }],
      "event": {
        "title": "...",
        "schedule": {
          "startDate": { "year": 2026, "month": 11, "day": 6 },
          "endDate":   { "year": 2026, "month": 11, "day": 6 },
          "startTime": { "hours": 9, "minutes": 0, "seconds": 0, "nanos": 0 }
        }
      },
      "offer": {
        "couponCode": "...",
        "redeemOnlineUrl": "https://...",
        "termsConditions": "..."
      }
    }
    

    event is emitted only for EVENT with a non-empty title; offer only for OFFER. Empty strings are omitted rather than sent as blanks.

  • languageCode is hard-coded "en". There is no setting for it; the summary text itself is whatever language you write.

  • Response handling:

    • state == "REJECTED" → BadBody: “Google rejected this post for a content policy violation. Please review the post content and try again.”
    • missing/empty name → BadBody: “Google did not confirm the post creation. Please try again.”
    • success → release URL is searchUrl when present, otherwise {GMB_WEB_BASE_URL}/locations/<last path segment of id>.
  • No pending state. supports_pending = false; the post is Success as soon as Google returns a resource name. There is no media-processing poll and no finalize_post.

Analytics

analytics() (location-level):

GET {GMB_PERFORMANCE_BASE_URL}/v1/locations/<locationId>:fetchMultiDailyMetricsTimeSeries
  ?dailyMetrics=WEBSITE_CLICKS
  &dailyMetrics=CALL_CLICKS
  &dailyMetrics=BUSINESS_DIRECTION_REQUESTS
  &dailyMetrics=BUSINESS_IMPRESSIONS_DESKTOP_MAPS
  &dailyMetrics=BUSINESS_IMPRESSIONS_MOBILE_MAPS
  &dailyRange.startDate.year=...&...&dailyRange.endDate...=...

It takes the first multiDailyMetricTimeSeries[0] entry, iterates its dailyMetricTimeSeries, and emits:

API metricLabel
WEBSITE_CLICKSWebsite Clicks
CALL_CLICKSPhone Calls
BUSINESS_DIRECTION_REQUESTSDirection Requests
BUSINESS_IMPRESSIONS_DESKTOP_MAPSDesktop Map Views
BUSINESS_IMPRESSIONS_MOBILE_MAPSMobile Map Views

Dates are formatted YYYY-MM-DD; value is a string and is parsed with parseInt semantics (leading integer, 0 on failure). Series with no points are omitted. Any error — including the Performance API not being enabled — is swallowed and analytics() returns [].

post_analytics() always returns [], despite supports_post_analytics = true. GBP local posts have no per-post metrics in the API; the flag is inherited from upstream metadata.

Plugs

Neither run_plug() nor run_post_plug() is overridden. Both use the trait defaults and return Unsupported for every method. There is no auto-repost or auto-comment plug for GMB.

Implementation Notes & Gotchas

  • GOOGLE_GMB_* wins per field, not per pair. client_id() prefers GOOGLE_GMB_CLIENT_ID and falls back to YOUTUBE_CLIENT_ID; client_secret() does the same independently. Mixing a GMB id with a YouTube secret works, which is convenient but easy to misconfigure. provider_status uses the paired alternatives, so it may say “configured” for a mixed set that still works.
  • The API access approval is tied to the project, not the OAuth client. If you switch projects, the new project has 0 QPM until approved again. The “Google My Business API” entry does not even appear in the library before approval.
  • business.manage is all-or-nothing. There is no per-location consent. The provider enumerates every account and location the user manages, which can be a lot for an agency account.
  • pages() is N+1 and partially silent. It makes one media request per location; a location-list error for one account breaks out of that account’s pagination without failing the whole call, so the returned list can be incomplete. Media failures only produce an empty picture. For an account with hundreds of locations this call is slow.
  • fetch_page_information requires locationName. The common mistake is to pass only {"page": ...} (LinkedIn style) or only {"id": ...}. GMB needs id, accountName and locationName, exactly as returned by pages.
  • The internal_id is the full resource name. accounts/123/locations/456 is used verbatim in /v4/{id}/localPosts. Do not shorten it to the location id; the fallback release URL is the only place the last segment is extracted.
  • CALL CTAs are effectively broken. callToAction is only emitted when both callToActionType and a non-empty callToActionUrl exist. A phone-call CTA in GBP does not take a URL, so a CALL setting without a URL is silently dropped, and with a URL Google may reject or ignore it. Use LEARN_MORE with a URL for reliable behavior.
  • Dates default to today, not to the event fields. An invalid/missing eventStartDate/eventEndDate becomes today; an EVENT with only a title can therefore be created as a zero-length event today. Always send both dates.
  • Times are parsed leniently. eventStartTime/eventEndTime split on :; non-numeric parts become 0. "9:5" becomes 09:05, "abc" becomes 00:00. There is no timezone field; GBP interprets the schedule in the location’s timezone.
  • languageCode is fixed to en. The API may localize the CTA label based on this value even if the summary is in another language. A per-location language setting is not implemented.
  • A post is public immediately. There is no draft/pending state; a successful localPosts.create is live on Search/Maps. Tests must account for that.
  • REJECTED is mapped to a content-policy message. Google can also reject for other reasons (e.g. an invalid CTA URL); the message always says “content policy violation”. Read the body in the failure details for the real reason. The body is preserved, except for token calls where without_request_body scrubs it.
  • PERMISSION_DENIED triggers a token refresh. handle_errors maps PERMISSION_DENIED to HandledError::refresh, so the engine refreshes the token and retries once before failing with “Permission denied. Please ensure you have access to this business location.” This is usually a location/role problem, not an expired token; the extra refresh is wasted but harmless.
  • The Unauthorized message mentions YouTube. It is a copy/paste from the YouTube provider (“Token expired or invalid, please reconnect your YouTube account.”). The behavior (refresh) is correct; the text is not.
  • Token exchange bodies are scrubbed from error details. without_request_body replaces the recorded request body with {} for BadBody/RefreshToken, because the body contains the client secret and the refresh token. Preserve this behavior if you refactor error handling.
  • Refresh tokens are not rotated by Google. details() falls back to the previous refresh token when the response omits one, so the channel keeps working. A 7-day Testing-mode expiry still kills it regardless.
  • refresh_token() returns the Google account, not the location. It calls userinfo and returns the account id/name/picture. Only the tokens and expiry are stored, so the location internal_id is preserved; do not treat the refresh result’s id as a location id.
  • expires_in can be 0. If Google returns neither expires_in nor expiry_date, the provider stores 0. Since refresh_cron is false, this does not cause a refresh loop; it only means the token’s expiry is unknown.
  • supports_post_analytics = true is a lie. post_analytics() returns []. Do not build UI that expects per-post GMB metrics.
  • Analytics errors are swallowed. A missing Performance API, a quota error or an unsupported metric/date combination all become []. Postiz’s own docs warn that the Performance API restricts metric/date combinations and returns no data for newer or unverified profiles.
  • Quota errors are BadBody, not provider retries. A 429 is retried by the generic HTTP layer twice (three attempts total, 5 s apart) and then fails; a RESOURCE_EXHAUSTED body is mapped to “Rate limit exceeded. Please try again later.” Either way there is no provider-level quota handling, so a sustained quota problem surfaces as a failed post/analytics call.
  • localPosts is a legacy v4 remnant. Accounts/locations/performance use v1 APIs, but posts and reviews still live on v4. A future Google migration would break this provider; there is no v1 local-posts replacement in the code.
  • No plug support and no comments. GMB cannot auto-repost or auto-comment in this port, and supports_comments = false.

References

Hashnode (hashnode)

Hashnode is a GraphQL-only article provider. Rustiz sends a Personal Access Token (PAT) and publishes with the publishPost mutation against https://gql-beta.hashnode.com. There is no OAuth app, no scope selection and no app review; Hashnode gates the API by publication plan, not by app review.

  • Rust source: crates/rustiz-providers/src/hashnode.rs
  • Static tag catalogue: crates/rustiz-providers/src/hashnode/tags.rs
  • Upstream port: hashnode.provider.ts + hashnode.tags.ts
  • Editor: Markdown
  • Connection mode: customFields

Overview

PropertyValue
Identifierhashnode
Display nameHashnode
Connection modecustomFields (Personal Access Token)
Editormarkdown
Channel kindsocial (article content)
Comments / threadsNo. supports_comments = false; only the first PostDetails is published
Pending / async publishingNo. supports_pending = false; success is PostStatus::Completed
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)10000 characters for contentMarkdown
APIGraphQL, single POST / endpoint

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. The PAT belongs to the user, not the deployment, so rustiz providers --status reports hashnode as configured without any server configuration.

VariableRequiredDefaultPurpose
HASHNODE_BASE_URLNohttps://gql-beta.hashnode.comOverrides the GraphQL origin. The value is trimmed of a trailing slash. The override replaces the whole base, so it must include any path prefix.
RESTRICT_UPLOAD_DOMAINSNounsetIf set, main_image.path must contain this substring.
NEXT_PUBLIC_BACKEND_URLNo""Prefix used for a relative main_image.path. See the cover-image bug below.
NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORYNo""Second part of the relative cover-image prefix. See the cover-image bug below.

The two NEXT_PUBLIC_* variables are read at publish time, not during connect. They are only relevant when main_image.path does not contain http.

Step-by-Step Developer App Setup

There is no developer application to register.

  1. Sign in to Hashnode.

  2. Confirm that the target publication is on a Hashnode Pro plan. Hashnode’s current docs and changelog state that the headless GraphQL API is a Pro feature (since 13 May 2026). The official gql-skill lists publishPost and publication-scoped reads as Pro-gated and returns FORBIDDEN with the message Publication does not have an active Pro plan when the plan is missing. Upgrade under the publication dashboard’s Billing page.

  3. Open Account Settings → Developer → API tokens. The direct URL is https://hashnode.com/settings/developer (it redirects through login).

  4. Click Generate new token, name it (for example Rustiz), and copy the PAT. It grants full write access to every publication the account can write to; treat it as a password.

  5. Optionally verify it before connecting:

    curl -s https://gql-beta.hashnode.com/ \
      -H "Authorization: Bearer $HASHNODE_PAT" \
      -H "Content-Type: application/json" \
      -d '{"query":"query { me { id username name } }"}'
    

    Hashnode accepts the token with or without the Bearer prefix. Rustiz sends the raw token with no prefix (see gotchas).

Products and permissions

  • No OAuth products or scopes. A PAT has the full permissions of the account.
  • The publication role still applies: an OWNER / EDITOR can publish; a CONTRIBUTOR can create drafts but publishing may require review.
  • The target publication must be Pro for publishPost and for publication-scoped reads.

Sandbox / testing mode

Hashnode has no sandbox. The API acts on the real publication.

  • Use a dedicated test publication on Pro, or accept that the post is public.
  • publishPost publishes immediately; Rustiz has no draft mode. To test a token without publishing, use a manual createDraft mutation or a me query.
  • The static tag catalogue can be inspected without any API call via the tags / tagsList provider functions.

Production app review

There is no app review and no approval queue. Access is controlled by the publication’s Pro subscription and by the PAT. The Pro-plan check happens at publish time, not at connect time, so a channel can connect successfully and then fail on the first post with a FORBIDDEN error.

Connecting a Channel (CLI & MCP)

The credential payload has exactly one key:

KeyTypeValidation regexNotes
apiKeypassword/^.{3,}$/Hashnode Personal Access Token

CLI

{ "apiKey": "hashnode_pat_xxxxxxxxxxxxxxxx" }
rustiz channels connect hashnode --fields hashnode-key.json

printf '%s' '{"apiKey":"hashnode_pat_xxxxxxxxxxxxxxxx"}' \
  | rustiz channels connect hashnode --fields -

Immediate result:

{ "connected": true, "pending": false, "name": "Your Hashnode name" }

MCP

{
  "provider": "hashnode",
  "customFields": {
    "apiKey": "hashnode_pat_xxxxxxxxxxxxxxxx"
  }
}

No follow-up step. Call integrationList for the channel id. Use --fields / customFields; --external-url / externalUrl is not the credential path for this provider.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
tagsnone[{"value": "<objectID>", "label": "<name>"}]Serves the static 1000-entry catalogue in hashnode/tags.rs. No API call.
tagsListnone[{"name": "...", "logo": "...", "slug": "...", "objectID": "..."}]Raw catalogue entries. logo is omitted when the catalogue has no logo.
publicationsnone[{"id": "...", "name": "<publication title>"}]GraphQL me { publications(first: 50) { edges { node { id title } } } }. Only the first 50 publications are listed.

MCP examples:

{ "integrationId": "int_...", "methodName": "tags" }
{ "integrationId": "int_...", "methodName": "publications" }

All three functions have an empty input_schema / dataSchema.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
titlestringYesminLength: 6; checked as at least 6 Unicode scalar values.
subtitlestringNominLength: 2 when present. Sent as the GraphQL subtitle input.
main_imageobjectNoCover image. Properties: id (string), path (string), alt (string), thumbnail (string URL). id and path are required by the schema.
canonicalstringNoSent as originalArticleURL. If it contains (post:, URL validation is skipped.
publicationstringYesPublication id. Required by schema and validator. Get it from the publications tool.
tagsarrayYesminItems: 1; each item needs value (string, the tag objectID) and label (string, display name). Hashnode itself caps tags at 15 per post; Rustiz does not enforce that cap.

Example object form:

{
  "title": "GraphQL pagination patterns",
  "subtitle": "Cursors without tears",
  "main_image": { "id": "m1", "path": "https://cdn.example/cover.png" },
  "canonical": "https://blog.example/graphql-pagination",
  "publication": "64f0c...",
  "tags": [
    { "value": "56744721958ef13879b94cad", "label": "JavaScript" }
  ]
}

MCP settings array:

{
  "settings": [
    { "key": "title", "value": "GraphQL pagination patterns" },
    { "key": "publication", "value": "64f0c..." },
    { "key": "tags", "value": [
      { "value": "56744721958ef13879b94cad", "label": "JavaScript" }
    ] }
  ]
}

The engine adds "__type": "hashnode" automatically if missing.

validate_settings() rules

  1. title must exist, be a string and have at least 6 characters.
  2. subtitle, if present and not null, must be a string of at least 2 characters.
  3. publication must exist and be a string.
  4. canonical, if present and a string, must be a valid URL unless it contains (post:.
  5. tags must exist and be an array with at least one item. Each item must be an object with a string value and a string label. Unlike the editor’s tag picker, the validator does not restrict the tag to the static catalogue.
  6. main_image, if present and not null:
    • must be an object;
    • id must be a string;
    • path must be a string;
    • if RESTRICT_UPLOAD_DOMAINS is set, path must contain it;
    • the path (query string stripped) must end in .png, .jpg, .jpeg, .gif, .webp or .mp4 (case-insensitive).
    • alt and thumbnail are in the schema but are not validated here.

Media requirements

  • Only main_image.path is used as coverImage; the post media / attachments array is ignored.
  • The cover must be a public URL (or a path the prefix logic can turn into one).
  • Only the path is sent. alt and thumbnail are ignored at publish time.
  • Hashnode’s own limits: image uploads must be image/* (no SVG) and at most 8 MB; request body at most 100 KB. Rustiz does not pre-check these.

check_validity() is the trait default (Ok(None)). The only media rule in this provider is the main_image extension/domain check in validate_settings(); the post media array is not inspected.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Raw Authorization token, no Bearer. graphql() sends .header("Authorization", access_token). Hashnode accepts the raw PAT in practice (the upstream provider does the same), but Hashnode’s official agent skill uses Authorization: Bearer. If a future API change rejects raw tokens, this is the first place to look.
  • GraphQL errors arrive with HTTP 200. post() checks response["errors"] and response["data"]["publishPost"]["post"] explicitly. The first error message becomes the user-facing error; if there is no error message but the payload is missing, the fallback is Hashnode could not publish the post. The error details are stored as a JSON array of GraphQL errors.
  • Pro gate is a bad_body, not a retry. A missing Pro plan returns a normal GraphQL error, which Rustiz surfaces as an actionable publish failure. Retrying will not help; upgrade the publication.
  • Relative cover images are double-prefixed. post() builds: prefix = "{NEXT_PUBLIC_BACKEND_URL}/{NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}" and then concatenates path with no separator. With NEXT_PUBLIC_BACKEND_URL=https://back.example and NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY=uploads, the path uploads/cover.png becomes https://back.example/uploadsuploads/cover.png (the test asserts exactly this). Use an absolute main_image.path starting with http to avoid the bug, or set the static directory to an empty string.
  • Unknown tag objectIDs become slug: null. The static catalogue maps value (objectID) to slug. If the id is not in tags.rs, the mutation contains tags: [{slug: null}], which Hashnode rejects. Always use ids returned by the tags function, not arbitrary tag names.
  • The tag catalogue is frozen at 1000 entries. New Hashnode tags will not be selectable until hashnode/tags.rs is regenerated. tagsList is the raw catalogue; tags is the editor-shaped {value,label} view.
  • Publications are capped at 50. The query uses first: 50; accounts with more publications cannot select the rest through the tool.
  • publication is mandatory. Unlike the upstream Postiz editor, Rustiz will not publish to the user’s personal blog implicitly. A publication id must be chosen.
  • The title limit is 6, not 2. This is stricter than Dev.to/Medium/ WordPress and comes from the Hashnode DTO.
  • No refresh. authenticate stores a 100-year expires_in and refresh_token: Some(""); refresh_token() returns an empty access token. Revoked PATs require reconnecting the channel.
  • No x-hashnode-client header. Hashnode’s official skill sends x-hashnode-client: gql-skill for telemetry. It is not required for authentication; Rustiz omits it.

References

Instagram — Facebook Business (instagram)

This provider publishes to an Instagram professional account through a Facebook Page using Facebook Login for Business. It is the “Instagram API with Facebook Login” setup: the Instagram account must be a Business or Creator account linked to a Facebook Page, and the token Rustiz stores is a Page access token paired with the user token.

If you want to connect an Instagram account without a Facebook Page, use instagram-standalone. The two providers share the same publishing core in instagram.rs; only the auth host and token flow differ.

  • instagram → FACEBOOK_APP_ID / FACEBOOK_APP_SECRET, Facebook Login for Business, a Facebook Page is required, token is {page_token}___{user_token}, is_between_steps = true, no automatic token refresh.

  • instagram-standalone → INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET, Business Login for Instagram, no Page, plain Instagram user token, is_between_steps = false, refresh_cron = true.

  • Rust source: crates/rustiz-providers/src/instagram.rs

  • Upstream port: instagram.provider.ts

  • Editor: Normal

  • Connection mode: oauth (Facebook Login for Business)

  • Shared implementation: InstagramCore is also used by instagram-standalone with graph.instagram.com

Overview

PropertyValue
Identifierinstagram
Display nameInstagram\n(Facebook Business)
Connection modeoauth (Facebook Login for Business)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries are posted as POST /{ig-media-id}/comments
Pending / async publishingYes. supports_pending = true; containers are created, polled to FINISHED, then media_publish
AnalyticsYes. supports_analytics = true; account insights
Post analyticsYes. supports_post_analytics = true; media insights
MentionsNo. supports_mentions = false (the platform supports mentions, but the provider does not implement them)
is_between_stepstrue — the OAuth callback creates an unfinished channel; an Instagram account must be selected with channelSetupTool
convert_to_jpegfalse — images are not converted, despite Instagram accepting only JPEG
max_concurrent_job400
Character limit (max_length)2,200 characters
Graph API versionv25.0
API hostshttps://graph.facebook.com (FACEBOOK_BASE_URL), https://www.facebook.com (FACEBOOK_WEB_BASE_URL)
Tool tip“Instagram must be business and connected to a Facebook page”

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
FACEBOOK_APP_IDYes—The Meta app ID, not the Instagram app ID. Shared with facebook.
FACEBOOK_APP_SECRETYes—The Meta app secret. Shared with facebook.
FACEBOOK_BASE_URLNohttps://graph.facebook.comGraph API origin for containers, publishing, insights and page discovery.
FACEBOOK_WEB_BASE_URLNohttps://www.facebook.comOrigin of the /v25.0/dialog/oauth login dialog.
INSTAGRAM_WEB_BASE_URLNohttps://www.instagram.comDefined by the shared core for the standalone provider; not used by this provider’s login flow.
INSTAGRAM_API_BASE_URLNohttps://api.instagram.comDefined by the shared core; not used by the Facebook Business flow.
INSTAGRAM_GRAPH_BASE_URLNohttps://graph.instagram.comUsed only by instagram-standalone.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard; Instagram fetches media from the URL, so it must be publicly reachable.

rustiz providers --status only checks FACEBOOK_APP_ID and FACEBOOK_APP_SECRET. It cannot tell whether the app has the Instagram product, the Instagram account is professional, or the account is linked to a Page.

Step-by-Step Developer App Setup

Facebook Page and Instagram (Facebook Business) can share one Meta app. If you already created an app for facebook, reuse it.

1. Create the Meta app

  1. Go to https://developers.facebook.com/apps/creation/.
  2. Choose Other → Business and create the app under a Business portfolio. Public apps need Business Verification.
  3. Add the product Facebook Login for Business.
  4. Add the product Instagram and choose Instagram API setup with Facebook login.
  5. In Facebook Login for Business → Settings, add the exact redirect URI from step 2.

2. Redirect URI (exact)

Instagram::redirect_uri() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/instagram", self.ctx.config.frontend_url())
}

Examples:

https://social.example.com/integrations/social/instagram   # production
http://localhost:5000/integrations/social/instagram        # Docker / rustiz serve
http://localhost:4200/integrations/social/instagram        # upstream Postiz dev UI

There is no redirectmeto.com wrapper for the Facebook Business flow, because Facebook accepts http://localhost redirect URIs. Register the value without a trailing slash.

Reconnect quirk: on ?refresh=<channel id>, the token exchange sends …/integrations/social/instagram?refresh=<id> while the login dialog used the plain URI. Meta requires an exact redirect-URI match; if the exchange fails with a redirect mismatch, start a fresh connection instead of using refresh.

3. Permissions requested and checked

Instagram::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec![
    "instagram_basic",
    "pages_show_list",
    "pages_read_engagement",
    "business_management",
    "instagram_content_publish",
    "instagram_manage_comments",
    "instagram_manage_insights",
];
}

The login URL joins them with commas. After the code → long-lived token exchange, authenticate_facebook() calls GET /v25.0/me/permissions and check_scopes() over the granted permissions. All seven must be granted, otherwise the connection fails with NotEnoughScopes.

ScopeWhy Rustiz needs it
instagram_basicRead the Instagram professional account profile/media
pages_show_listDiscover Facebook Pages and their linked Instagram accounts
pages_read_engagementRead Page metadata and linked IG account
business_managementDiscover Pages through Business Manager (owned_pages, client_pages)
instagram_content_publishCreate containers and media_publish
instagram_manage_commentsPost comments/replies
instagram_manage_insightsAccount and media insights

The account must be able to perform the Page tasks CREATE_CONTENT or MANAGE (for publishing) and MODERATE (for comments) on the linked Page. Instagram account roles are managed by the Page’s Business Manager assignment.

4. Account prerequisites

  • The Instagram account must be Business or Creator (professional). A personal account cannot be connected; the API returns “The user is not an Instagram business account”, which Rustiz maps to a refresh error.
  • The professional account must be linked to a Facebook Page.
  • The Page must be published and must not be blocked by Page Publishing Authorization (PPA). An account whose Page requires PPA cannot publish until PPA is completed.
  • The user must have full control of the Page or the relevant Page task.

5. App roles and Development mode

  • All new apps start in Development mode. Only Admins, Developers and Testers can grant permissions and see content.
  • For Instagram (Facebook Business), the tester’s Facebook account must be able to manage the Page; the Instagram account is reached through that Page.
  • In Development mode, only role users can publish. The Postiz docs warn that posts made with media in Development mode may be visible only to the app’s role users.
  • Instagram Tester roles are primarily for the standalone flow; for the Facebook Business flow the Page role is what matters. Adding the account as an Instagram Tester is still useful when diagnosing “Insufficient developer role” errors.

6. Live mode and App Review

  • Switch to Live only after App Review. Live mode exposes only permissions approved through App Review.
  • Advanced Access is required to serve Instagram accounts you do not own or manage. It needs App Review + Business Verification.
  • Standard Access is enough for an app used only by its own role users.
  • App Review needs a screencast showing the complete Facebook login, the Instagram account selection, a published feed post/carousel/Reel, and a comment. Vague use-case descriptions are a common rejection.
  • Permissions unused for 90 days may expire.

7. Sandbox / testing

Instagram has no separate sandbox. Use:

  • a test Meta app in Development mode;
  • a test Facebook Page and a test Instagram professional account linked to it;
  • role users only.

Containers are invisible until media_publish, so a failed processing run leaves no public post — a useful property for testing. A container that was never published expires after 24 hours.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect instagram
rustiz channels connect instagram --open
rustiz channels connect instagram --timeout 600

The login URL goes to stderr. With a local FRONTEND_URL the CLI serves the callback and waits; with a public HTTPS FRONTEND_URL it prints the URL and exits.

The callback creates the channel with inBetweenSteps = true. There is no CLI setup command; finish it with the MCP channelSetupTool. Until then the scheduler refuses the channel with “channel … is not set up yet, finish it with channelSetupTool”.

MCP connectChannelTool

{ "provider": "instagram" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.facebook.com/v25.0/dialog/oauth?client_id=…&redirect_uri=…&state=…&scope=instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments,instagram_manage_insights",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with { "connectionId": "…" }.

Finishing setup: triggerTool then channelSetupTool

  1. List the Instagram accounts reachable through the connected Pages:

    {
      "integrationId": "<unfinished channel id>",
      "methodName": "pages",
      "dataSchema": []
    }
    

    instagram exposes two functions:

    FunctionDescriptionInput
    pages“Instagram business accounts reachable through the connected Facebook pages”{}
    audioSearch“Search audio (music or original sounds) to attach to a Reel via the "audio" setting, an empty query returns trending audio”{q?: string, type?: "music" | "original_sound"}

    pages returns:

    [
      {
        "pageId": "1234567890",
        "id": "17841400000000000",
        "name": "My Instagram",
        "picture": { "data": { "url": "https://…" } }
      }
    ]
    

    pageId is the Facebook Page id; id is the Instagram professional account id used for publishing.

  2. Choose an account and call:

    {
      "integrationId": "<unfinished channel id>",
      "data": {
        "id": "17841400000000000",
        "pageId": "1234567890"
      }
    }
    

    channelSetupTool passes data straight to Instagram::fetch_page_information(), which:

    • reads GET /{pageId}?fields=access_token,name,picture.type(large);
    • reads GET /{id}?fields=username,name,profile_picture_url;
    • stores the token as "{page_access_token}___{user_access_token}", the internal id as the Instagram id, the profile as the IG username, and clears inBetweenSteps.
  3. Verify with integrationList: the channel name is the Instagram name, profile is the username and inBetweenSteps is false.

Both keys are required. {"id": …} alone leaves pageId empty and the Graph call becomes /{empty}?fields=access_token…; {"pageId": …} alone leaves the Instagram id empty.

What not to send

  • customFields routes to connect_custom() and is treated as an OAuth code. Never send it to instagram.
  • externalUrl is unused; do not pass it.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "post_type": {
      "type": "string",
      "enum": ["post", "story"],
      "description": "post = a regular feed post or carousel, story = publish the media as stories (stories can only hold one picture)"
    },
    "is_trial_reel": {
      "type": "boolean",
      "description": "Publish the single video as a Trial Reel (trial Reels are shown to non-followers first; the account must support them)"
    },
    "graduation_strategy": {
      "type": "string",
      "enum": ["MANUAL", "SS_PERFORMANCE"],
      "description": "How a Trial Reel graduates to a regular Reel: MANUAL requires manual graduation, SS_PERFORMANCE graduates automatically when it performs well"
    },
    "collaborators": {
      "type": "array",
      "description": "Accounts invited as collaborators on a feed post or Reel; labels may include a leading @, it is stripped before publishing",
      "items": {
        "type": "object",
        "properties": {
          "label": { "type": "string", "description": "The collaborator username" }
        },
        "required": ["label"]
      }
    },
    "audio": {
      "type": "object",
      "description": "Audio (music or original sound) attached to a single Reel; not supported on stories, images or carousels",
      "properties": {
        "id": { "type": "string", "description": "The audio id returned by the audio search tool" },
        "title": { "type": "string", "description": "Display title of the audio" },
        "artist": { "type": "string", "description": "Display artist of the audio" },
        "image": { "type": "string", "description": "Cover artwork URL of the audio" },
        "audio_volume": { "type": "number", "minimum": 0, "maximum": 100 },
        "video_volume": { "type": "number", "minimum": 0, "maximum": 100 }
      },
      "required": ["id"]
    }
  },
  "required": ["post_type"],
  "additionalProperties": false
}
FieldRequiredBehaviour
post_typeYespost = feed image/video/carousel; story = story media. A story with multiple media publishes each as a separate story
is_trial_reelNoOnly for a single video. Adds trial_params={"graduation_strategy": …}. The account must support Trial Reels
graduation_strategyNoDefaults to MANUAL when is_trial_reel is true
collaboratorsNoArray of {label}. A leading @ is stripped. Sent as a JSON array; not allowed on stories or carousel children; allowed on the carousel parent
audioNoAudio id/title/artist/image/volumes. Only applied on graph.facebook.com, non-story, single-video Reels. Ignored silently on standalone and on carousels/stories/images

additionalProperties: false is advisory: the exported schema tells UI/MCP clients not to send unknown keys, but the Rust validate_settings() ignores extra keys rather than rejecting them.

validate_settings()

  • settings must be an object.
  • post_type must be exactly post or story; it is not defaulted.
  • is_trial_reel, when non-null, must be boolean or string (the string is parsed by as_bool).
  • graduation_strategy, when non-null, must be MANUAL or SS_PERFORMANCE.
  • collaborators, when non-null, must be an array; each item must be an object with a non-empty string label.
  • audio, when non-null:
    • must be an object;
    • id must be a string (an empty string is accepted and means “no audio”);
    • title, artist, image must be strings when present;
    • audio_volume and video_volume must be numbers in [0, 100].
  • graduation_strategy is validated even when is_trial_reel is false.
  • No maximum caption length is checked here; max_length handles the engine side.

check_validity()

The shared instagram_check_validity(posts, settings, carousel_checks = true) enforces:

  1. At least one media item in the first post.
  2. At most 10 media items in the first post: “Instagram carousel only supports up to 10 media attachments”.
  3. If is_trial_reel:
    • only one media;
    • that media must be a video (path contains .mp4).
  4. Alt text on any item must be ≤ 1,000 UTF-16 code units.
  5. If an audio id is set:
    • not allowed on stories;
    • not allowed with more than one media;
    • the single media must be a video.

It does not check:

  • JPEG-only images (the API requires JPEG; Rustiz sends PNG/WebP as-is and convert_to_jpeg is false);
  • image dimensions, 8 MB size, or 4:5–1.91:1 aspect ratio;
  • video duration/codec/size;
  • caption length beyond max_length;
  • collaborator validity (the API returns 2207018).

Media requirements

Platform rules enforced by Instagram (and mapped by handle_errors):

MediaRule
Feed imageJPEG only, max 8 MB, aspect ratio 4:5 to 1.91:1, width 320–1,440 px (Meta scales outside that range)
Carousel2–10 items in the Rust provider; current Meta docs describe up to 20 in one section but error 2207028 and most reference pages still document 10. Treat 10 as the safe limit. Children are cropped to the first item’s ratio
Single videoPublished as a Reel: MP4/MOV, H.264/HEVC + AAC, 3 s–15 min, ≤ 300 MB, 23–60 fps, width ≤ 1,920, bitrate ≤ 25 Mbps
Story imageJPEG, ≤ 8 MB, 9:16 recommended
Story videoMP4/MOV, 3–60 s, ≤ 100 MB, 9:16 recommended
Alt textImages only (single or carousel item), max 1,000 characters, ignored on videos and stories
CollaboratorsUp to 3 on a feed post/Reel; account must be public and allow invites
AudioSingle-video Reel only; not on stories, images or carousels

How Rustiz builds each container:

  • Single image: POST /{ig-id}/media?image_url=…&caption=… (no media_type, so Instagram defaults to IMAGE).
  • Single video, feed: video_url=…&media_type=REELS, plus cover_url when a thumbnail is provided, otherwise thumb_offset (0 when absent).
  • Single video, story: video_url=…&media_type=STORIES.
  • Single image, story: image_url=…&media_type=STORIES.
  • Carousel child: image_url/video_url, media_type=VIDEO for videos, is_carousel_item=true, no caption.
  • Carousel parent: created later in finalize_post() with caption, media_type=CAROUSEL, children=<ids>, optional collaborators.
  • Multiple stories: each media is its own STORIES container; they are published one at a time.

URL encoding bug: media paths are interpolated into the query string without percent-encoding (image_url={path}, video_url={path}). A signed URL containing ?, & or # will be truncated or misparsed. Host media on a plain public URL without query parameters.

Threads

supports_comments = true, but Instagram threads are comments on the first media, not separate feed posts:

  1. The first entry creates the containers and publishes the feed post/Reel/story.
  2. Each later entry calls POST /{post-id}/comments?message=….
  3. comment() ignores last_comment_id — every comment is attached to the root media, not to the previous comment. A multi-entry thread therefore looks like several top-level comments, not a reply chain.
  4. A comment can carry no media; Instagram comments are text-only in this provider.

Plugs

instagram.rs has no @Plug or @PostPlug methods. run_plug() and run_post_plug() return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Two ids, two tokens, one string. After setup the stored token is "{page_access_token}___{user_access_token}" (split_token splits on ___). Publishing uses the page token; container status checks and permalink reads prefer the user token when present. If the user token is missing, the page token is used for both.
  • The page token is optional at setup. fetch_page_information() does not reject an empty page.access_token; it builds "___<user token>". Publishing then fails as the Page. The Facebook provider does check for this case.
  • Empty pages list can hide a permission problem. InstagramCore::pages() fetches each Page’s Instagram profile separately and uses let Ok(information) = … else { continue }. A Page whose IG profile read fails (missing permission, deleted account, restricted Page) is silently omitted from the picker instead of producing an error. If the expected account is missing, call the Graph endpoint manually with the same token.
  • You must finish setup. The OAuth callback stores a user token and the Facebook user id. Until channelSetupTool runs, internal_id is the user id, not an Instagram account id, and publishing targets the wrong object.
  • JPEG-only means JPEG-only. convert_to_jpeg is false, and the provider never re-encodes images. Upload PNG/WebP and Instagram returns 2207005 (“Unsupported image format”) or 2207004 (too large). Convert to JPEG before uploading to Rustiz.
  • Carousel limit mismatch. Rustiz enforces 10; Meta’s 2026 content publishing page says “up to 20” in the carousel section while the error-code documentation and the IG User Media reference still describe 10. The provider’s hard 10-item check is the conservative choice; if you raise it, test with the live API.
  • Daily publish cap mismatch. handle_errors maps 2207042 to “maximum of 25 posts per day”, but Meta’s current Content Publishing guide states 100 API-published posts per 24-hour moving period (carousel counts as one). The error text is stale; the platform cap is higher.
  • No token refresh. refresh_token() returns an empty access token with expires_in: 0; refresh_cron is false. The long-lived user token is stored with a hard-coded 59-day expiry. When it expires, a 190 or REVOKED_ACCESS_TOKEN triggers the engine’s refresh path, which fails and marks the channel refreshNeeded; the user must reconnect.
  • Reconnect id mismatch. After setup, internal_id is the Instagram id. If the OAuth callback is called with ?refresh=<ig id>, save() compares it to authenticate()’s /me id, which is the Facebook user id, and rejects with “Please refresh the channel that needs to be refreshed”. Reconnect by creating a new channel. The reconnect() implementation (which re-derives the page token) is only used for root/sub-account setups and Instagram channels have no root.
  • Container status polling is one read per check. check_post_status() reads each container’s status_code once and returns Pending on IN_PROGRESS. finalize_post() creates the carousel parent and hands back to the workflow, so the parent is also polled. There is no long sleep inside the durable path; the blocking post() compatibility path sleeps 30 s.
  • ERROR/EXPIRED containers. container_status() maps the status text through instagram_handle_errors(). A Disconnect classification becomes a disconnect; everything else becomes a bad-body error with the platform message, falling back to “Instagram could not process the media”.
  • media_publish blocked but live. Instagram sometimes returns 2207051 (“action is blocked”) after actually publishing the container. finalize_post() probes the container; if it reports PUBLISHED, it reads the newest /{ig-id}/media?fields=id&limit=1 and treats that as success instead of failing a live post. If the probe fails, the original error is returned.
  • Carousel parent crash safety. If a run dies after creating the carousel parent, the id is stored in pending.carousel_id; the next check polls it instead of creating a second parent. Orphan containers are invisible and expire after 24 hours.
  • Stories are not carousels. A multi-media story is stored as post_type: "stories" and each container is published separately, skipping containers already PUBLISHED. The final post_id is the last published media id, or the last container id if everything was already published.
  • Per-media processing means slow carousels. All child containers are created in parallel (try_join_all), but the status check reads them sequentially. A 10-video carousel can take several minutes.
  • Collaborators are handled carefully. A leading @ is stripped; the JSON array is only sent for non-stories; child items never get the parameter (Instagram rejects it on children); the parent carousel container gets it. 2207018 is mapped to a collaborator-specific message.
  • Audio is silently dropped outside its supported shape. audio_configuration is only added when domain == graph.facebook.com, non-story, exactly one media, and the media is a video. On the standalone provider (graph.instagram.com) the same settings object is accepted by validation but never sent.
  • Trial Reels can fail for account-specific reasons. 2207081 (account does not support Trial Reels) and 2207078 (publish limit) are mapped to actionable messages. The account must be eligible; there is no pre-flight check.
  • Analytics. Account analytics makes two calls: follower_count,reach with period=day, then metric_type=total_value&metric=likes,views,comments,shares,saves,replies. Post analytics requests views,reach,saved,likes,comments,shares and reads values[0].value. Every error is swallowed and returns []. Percentage change is hard-coded to 5.0 for account metrics and 0.0 for post metrics.
  • Insight metric names drift. saved vs saves, shares availability, and views on older media vary by media product type and Graph version. An unsupported metric can make the whole insights response an error, which the provider turns into an empty analytics list.
  • Rate limits. Instagram publishing is capped at 100 posts per 24 hours per account (carousel = 1). General Graph calls use 4800 × impressions per rolling 24 hours per app/user pair. Comments have their own limits. max_concurrent_job = 400 is far above what the publishing cap allows in a day; it controls worker concurrency, not the daily quota.
  • Error mapping highlights. 2207050 restricted user; 2207003 media download timeout; 2207020 expired media; 2207026 unsupported video format; 2207028 carousel validation; 2207010 caption too long; 2207004 image too large; 2207005 unsupported image; 2207009/36003 aspect ratio; 2207042 daily limit; 2207001 spam; 1772107 comment rejected but post published; 2207082 audio on a silent video; 2207085 video processing; 2207077 download failed. (#200) is a Page permission error. “You cannot access the app till you log in to” and “Session key is malformed” produce a disconnect (checkpoint), not a refresh.
  • Graph version. v25.0 is compiled in. Meta’s 2026 App Dashboard defaults to newer versions; older versions remain callable for a transition period. Plan an upgrade before the version is retired.

References

Instagram — Standalone (instagram-standalone)

This provider uses the Instagram API with Instagram Login (Business Login for Instagram). It connects directly to an Instagram professional account with Instagram credentials, without a Facebook Page. Publishing goes to graph.instagram.com, and the token is an Instagram User access token.

The publishing core is shared with instagram; only auth, refresh and media validity differ.

Aspectinstagraminstagram-standalone
App credentialsFACEBOOK_APP_ID / FACEBOOK_APP_SECRETINSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET
Login productFacebook Login for Business + Instagram API setup with Facebook loginBusiness Login for Instagram + API setup with Instagram login
Facebook PageRequiredNot required
API hostgraph.facebook.comgraph.instagram.com
Redirect URI{FRONTEND_URL}/integrations/social/instagram{redirectmeto or FRONTEND_URL}/integrations/social/instagram-standalone
Stored token{page_token}___{user_token}Instagram user access token
is_between_stepstrue (select an IG account)false
refresh_cronfalse (no automatic refresh)true (ig_refresh_token)
Audio in ReelsYes (Facebook Graph only)Silently ignored
Local carousel cap10None (platform enforces)
  • Rust source: crates/rustiz-providers/src/instagram_standalone.rs
  • Shared core: crates/rustiz-providers/src/instagram.rs (InstagramCore)
  • Upstream port: instagram.standalone.provider.ts
  • Editor: Normal
  • Connection mode: oauth (Business Login for Instagram)

Overview

PropertyValue
Identifierinstagram-standalone
Display nameInstagram\n(Standalone)
Connection modeoauth (Business Login for Instagram)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries become comments on the root media
Pending / async publishingYes. supports_pending = true; containers → poll → media_publish
AnalyticsYes. supports_analytics = true
Post analyticsYes. supports_post_analytics = true
MentionsNo. supports_mentions = false
is_between_stepsfalse — the account is stored directly, no page/account selection step
convert_to_jpegfalse
max_concurrent_job200 (“standalone has stricter limits”)
Character limit (max_length)2,200
Graph API versionv25.0
API hostshttps://graph.instagram.com (INSTAGRAM_GRAPH_BASE_URL), https://api.instagram.com (INSTAGRAM_API_BASE_URL), https://www.instagram.com (INSTAGRAM_WEB_BASE_URL)
Token refreshYes. refresh_cron = true; ig_refresh_token

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["INSTAGRAM_APP_ID", "INSTAGRAM_APP_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
INSTAGRAM_APP_IDYes—The Instagram App ID from App Dashboard → Instagram → API setup with Instagram login → Business login settings. Not the Meta/Facebook app ID.
INSTAGRAM_APP_SECRETYes—The Instagram App Secret from the same panel (32 characters).
INSTAGRAM_GRAPH_BASE_URLNohttps://graph.instagram.comHost for /v25.0/me, /access_token, /refresh_access_token and all publishing calls.
INSTAGRAM_API_BASE_URLNohttps://api.instagram.comHost for the code → short-lived-token exchange (POST /oauth/access_token).
INSTAGRAM_WEB_BASE_URLNohttps://www.instagram.comHost of the authorization window (/oauth/authorize).
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI, wrapped in redirectmeto.com when it is not HTTPS.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Instagram fetches media from the URL, so it must be public.

rustiz providers --status checks only INSTAGRAM_APP_ID and INSTAGRAM_APP_SECRET. It cannot tell whether the app has API setup with Instagram login configured or whether the tester invitation was accepted.

Step-by-Step Developer App Setup

1. Create the Meta app

  1. Go to https://developers.facebook.com/apps/creation/.
  2. Choose Other → Business (a Business portfolio is recommended).
  3. Add the product Instagram.
  4. In the Instagram product, choose API setup with Instagram login.
  5. Open 3. Set up Instagram business login → Business login settings. Copy the Instagram App ID and Instagram App Secret.

This is a different app ID/secret pair from the Meta app ID used by facebook and instagram (Facebook Business). The App Dashboard displays both; make sure you copy the pair from the Instagram API setup with Instagram login panel.

2. Redirect URI (exact, including redirectmeto.com)

InstagramStandalone::redirect_uri() is:

#![allow(unused)]
fn main() {
let frontend = self.ctx.config.frontend_url();
let base = if frontend.contains("https") {
    frontend
} else {
    format!("https://redirectmeto.com/{frontend}")
};
format!("{base}/integrations/social/instagram-standalone")
}

Config::frontend_url() trims the trailing slash, so the resulting values are:

FRONTEND_URLRegistered redirect URI
https://social.example.comhttps://social.example.com/integrations/social/instagram-standalone
http://localhost:5000https://redirectmeto.com/http://localhost:5000/integrations/social/instagram-standalone
http://localhost:4200https://redirectmeto.com/http://localhost:4200/integrations/social/instagram-standalone
http://127.0.0.1:5000https://redirectmeto.com/http://127.0.0.1:5000/integrations/social/instagram-standalone

The Instagram login window requires an HTTPS redirect URI, which is why the provider tunnels a plain-HTTP local frontend through redirectmeto.com. Register the redirectmeto URL, not the localhost URL, in the App Dashboard. After the human authorizes, redirectmeto.com 302s the browser back to http://localhost:…/integrations/social/instagram-standalone?code=…&state=….

Quirks:

  • The check is frontend.contains("https") — a substring test, not a URL scheme parse. Any FRONTEND_URL containing the letters https skips the wrapper. This is almost always what you want for https://…, but it also matches odd hosts/paths containing https.
  • There is no trailing slash between the frontend and the path.
  • If FRONTEND_URL already has a path prefix (https://example.com/app), the callback becomes https://example.com/app/integrations/social/instagram-standalone.

3. Scopes requested and checked

InstagramStandalone::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec![
    "instagram_business_basic",
    "instagram_business_content_publish",
    "instagram_business_manage_comments",
    "instagram_business_manage_insights",
];
}

The authorize URL joins them with commas:

https://www.instagram.com/oauth/authorize
  ?enable_fb_login=0
  &client_id={INSTAGRAM_APP_ID}
  &redirect_uri={redirectmeto or https URI}
  &response_type=code
  &scope=instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_insights
  &state={state}

There is no force_reauth parameter, and enable_fb_login=0 hides the “Log in with Facebook” option so the account is always an Instagram login.

After the token exchange, check_scopes_value() validates the permissions field from the short-lived token response:

  • if it is an array, every required scope must be present;
  • if it is a string, check_scopes() splits on comma or space;
  • anything else → NotEnoughScopes.

instagram_business_manage_messages is not requested; Rustiz does not send DMs.

ScopeWhy Rustiz needs it
instagram_business_basicRead the profile (/me) and media
instagram_business_content_publishCreate containers and publish
instagram_business_manage_commentsPost comments/replies
instagram_business_manage_insightsAccount and media insights

4. Token flow performed by Rustiz

  1. Authorization code → POST https://api.instagram.com/oauth/access_token as multipart/form-data with client_id, client_secret, grant_type=authorization_code, redirect_uri, code.
  2. Short-lived → long-lived → GET https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_id=…&client_secret=…&access_token=…
  3. Profile → GET https://graph.instagram.com/v25.0/me?fields=user_id,username,name,profile_picture_url&access_token=…
  4. Stores id = me.user_id, name, picture, username, and access_token = refresh_token = long-lived token, expires_in = 58 days.

Response-shape risk: Meta’s Business Login sample returns the short-lived token under {"data":[{"access_token":…,"user_id":…,"permissions":…}]}, but this provider reads short["access_token"] and short["permissions"] at the top level. If your app receives the wrapped shape, authenticate_code() sees an empty token and fails with “Instagram token exchange failed”. Check the raw response with curl before debugging anything else; a small compatibility shim (read data[0] when present) is the likely fix.

5. App roles: Instagram Tester

While the app is in Development mode, only users with a role can authorize it. For Instagram Login that means an Instagram Tester:

  1. App Dashboard → App roles → Roles → Testers → Add People.
  2. Choose Instagram Tester and enter the Instagram username.
  3. On the Instagram account, open Settings → Website permissions → Apps and websites → Invites and accept the invitation (https://www.instagram.com/accounts/manage_access/).
  4. Retry the connection.

The common error “Insufficient developer role” means this invitation was not sent or not accepted for the exact account.

6. Development mode vs Live / App Review

  • Development mode (default): tester accounts only. Standard Access is enough for accounts you own/manage.
  • Live mode + Advanced Access: required to serve Instagram professional accounts you do not own or manage. It needs App Review + Business Verification.
  • App Review needs a screencast of the complete Instagram login, permission grant, and a published post/Reel/carousel plus a comment.
  • Permissions unused for 90 days may expire and must be re-granted.

7. Sandbox / testing

There is no separate sandbox host. Use the real Instagram API with a test professional account added as an Instagram Tester. Containers are invisible until media_publish, and unpublished containers expire after 24 hours, so a failed test leaves no public content.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect instagram-standalone
rustiz channels connect instagram-standalone --open
rustiz channels connect instagram-standalone --timeout 600

The login URL goes to stderr; with a local FRONTEND_URL the CLI serves the callback on the frontend port and waits for the account. With a public HTTPS FRONTEND_URL it prints the URL and exits.

Because is_between_steps is false, the account is usable as soon as the callback completes. There is no triggerTool / channelSetupTool step and the CLI returns the finished channel:

{
  "url": "https://www.instagram.com/oauth/authorize?...",
  "connected": true,
  "channel": {
    "id": "…",
    "name": "My Studio",
    "identifier": "instagram-standalone",
    "profile": "mystudio",
    "type": "social",
    "refreshNeeded": false
  }
}

MCP connectChannelTool

{ "provider": "instagram-standalone" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.instagram.com/oauth/authorize?enable_fb_login=0&client_id=…&redirect_uri=…&response_type=code&scope=…&state=…",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll:

{ "connectionId": "…" }

integrationSchema reports oauth, headless: false, requiresHuman: true, requiresExternalUrl: false. There is no follow-up triggerTool or channelSetupTool.

What not to send

  • customFields routes to connect_custom() and is used as an OAuth code. Never send it.
  • externalUrl is unused; do not pass it.

Post Settings & Media Validation

instagram-standalone reuses the exact schema, validator and media settings of instagram:

#![allow(unused)]
fn main() {
fn settings_schema(&self) -> Value {
    crate::instagram::instagram_settings_schema()
}
fn validate_settings(&self, settings: &Value) -> Result<(), String> {
    crate::instagram::instagram_validate_settings(settings)
}
}
{
  "type": "object",
  "properties": {
    "post_type": { "type": "string", "enum": ["post", "story"] },
    "is_trial_reel": { "type": "boolean" },
    "graduation_strategy": { "type": "string", "enum": ["MANUAL", "SS_PERFORMANCE"] },
    "collaborators": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": { "label": { "type": "string" } },
        "required": ["label"]
      }
    },
    "audio": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "title": { "type": "string" },
        "artist": { "type": "string" },
        "image": { "type": "string" },
        "audio_volume": { "type": "number", "minimum": 0, "maximum": 100 },
        "video_volume": { "type": "number", "minimum": 0, "maximum": 100 }
      },
      "required": ["id"]
    }
  },
  "required": ["post_type"],
  "additionalProperties": false
}

The descriptions are identical to the Instagram settings table: post_type is required and has no default; is_trial_reel is a boolean; graduation_strategy is MANUAL or SS_PERFORMANCE; each collaborator needs a non-empty label; audio.id must be a string and the volumes must be 0–100.

check_validity()

The standalone provider calls the shared validator with carousel_checks = false:

#![allow(unused)]
fn main() {
instagram_check_validity(posts, settings, false)
}

That means:

  • at least one media in the first post;
  • if is_trial_reel: exactly one video;
  • alt text ≤ 1,000 UTF-16 units;
  • no 10-item carousel cap (the platform still enforces its own limit; the current docs say 10 in most places, 20 in one section);
  • no audio rules (the standalone API cannot attach Instagram audio anyway).

The audio setting remains in the schema for compatibility, validates normally, and is silently ignored at publish time because create_container() only adds audio_configuration when the host is graph.facebook.com.

Media requirements

Same platform constraints as the Facebook Business provider:

  • Images: JPEG only, ≤ 8 MB, aspect ratio 4:5–1.91:1, width 320–1,440 px.
  • Feed video: published as a Reel, MP4/MOV, 3 s–15 min, ≤ 300 MB, H.264/HEVC + AAC, 23–60 fps.
  • Story image: JPEG, ≤ 8 MB, 9:16 recommended.
  • Story video: 3–60 s, ≤ 100 MB.
  • Carousel: 2–10 (Rustiz does not cap it here; the API does).
  • Alt text: images only, ≤ 1,000 characters.
  • Public media URLs: Instagram cURLs the URL; it must be publicly reachable.

convert_to_jpeg is false, so PNG/WebP are not converted and will be rejected by Instagram. Upload JPEG.

Threads

Same as instagram: the first entry is the feed post/Reel/story; later entries are comments on the root media. comment() ignores last_comment_id, so all comments are top-level comments on the root post.

Plugs

No @Plug or @PostPlug methods. run_plug() / run_post_plug() return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Different app credentials. INSTAGRAM_APP_ID/INSTAGRAM_APP_SECRET are the Instagram Login pair. Using the Meta/Facebook app ID here produces an “Invalid client_id” or a login window that never authorizes the app.
  • Redirect URI must be HTTPS and exact. Register the fully expanded https://redirectmeto.com/http://localhost:…/integrations/social/instagram-standalone value when developing locally. Registering only http://localhost:… fails because Instagram requires HTTPS.
  • The redirectmeto.com wrapper is decided at request time. If you change FRONTEND_URL from http://… to https://…, the redirect URI changes and must be re-registered in the App Dashboard.
  • Token response shape. As noted above, the code reads top-level access_token/permissions; Meta’s current sample nests them under data[0]. This is the most likely cause of a connection that fails before /me is ever called.
  • Refresh actually works here. refresh_cron = true; the hourly sweep refreshes any token expiring within 2 hours via GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=…. Meta requires the token to be at least 24 hours old and the instagram_business_basic grant to still be valid. A token not refreshed within 60 days cannot be refreshed; reconnect.
  • 58-day expiry. expires_in is hard-coded to 58 * 24 * 3600 even though Meta says 60 days. The 2-day margin is deliberate; do not “fix” it without accounting for the sweep window.
  • me returns user_id, not id. The provider stores me.user_id as the internal id (unlike the Facebook Business provider, which stores the IG id from a Page lookup). username is stored as the profile. id is not requested, so a response without user_id stores an empty id. The code also expects a flat object (me["user_id"]); some Meta samples wrap the user in a data array. If the channel name/id come back empty, inspect this response shape.
  • app_id/app_secret are read on every auth/refresh. Changing INSTAGRAM_APP_ID or INSTAGRAM_APP_SECRET affects existing channels on the next refresh, not just new connections.
  • The client secret is sent in a query string for the long-lived exchange (GET /access_token?...&client_secret=…). That is Meta’s documented shape, but it means the secret can appear in proxy/access logs. Ensure the server’s egress logging redacts query strings.
  • No pages / audioSearch helper. functions() is not overridden, so triggerTool cannot list accounts or search audio. The channel is complete after OAuth.
  • Same publishing core, different host. Containers, carousel parents, stories, comments, insights and the 2207051 “blocked but published” probe all run against graph.instagram.com. Any Graph API difference between the two hosts (metric availability, audio, collaborators) surfaces here first.
  • No carousel cap locally. A 25-item carousel passes Rustiz validation and fails at Instagram. Keep the client at 10 or fewer unless you have verified the account’s current limit.
  • Audio silently dropped. A post with audio.id set validates and publishes without audio; no warning is emitted. Use the Facebook Business provider if you need Instagram music/original sound.
  • Rate limits. 100 API-published posts per 24-hour moving period (carousel = 1). General Graph calls are capped by 4800 × impressions per 24 hours per app/user pair. max_concurrent_job = 200 is worker concurrency, not a quota.
  • Error mapping is shared. See the Instagram error table: 2207004/2207005/2207009 image errors, 2207028 carousel, 2207042 daily limit, 2207051 blocked, 2207001 spam, 1772107 comment rejection, (#200) missing Page permission, checkpoint → disconnect. The “maximum of 25 posts per day” text for 2207042 is stale.
  • Analytics. Same two-call account insights and one-call media insights as the Facebook Business provider. All errors are swallowed and return []; percentage change is hard-coded. saved/shares/views availability varies by media type and API version.
  • App Review is per-permission. Adding instagram_business_manage_insights to the requested scopes does not grant it; each permission needs approval for non-tester users, and the app must be Live.

References

Kick (kick)

Kick is a live-streaming platform. Rustiz posts a chat message to the broadcaster’s channel through the Kick public API, using OAuth 2.1 with PKCE. Threads become chat replies (reply_to_message_id). There is no stream management, clip creation or media upload.

  • Rust source: crates/rustiz-providers/src/kick.rs
  • Upstream port: kick.provider.ts
  • Editor: Normal
  • Connection mode: oauth (authorization code + PKCE)
  • No sandbox and no app review for ordinary chat apps.

Overview

PropertyValue
Identifierkick
Display nameKick
Connection modeoauth (authorization code grant with PKCE S256)
Editornormal
Channel kindsocial
Comments / threadsAdvertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”.
Pending / async publishingNo. supports_pending = false; post() sends and returns Posted/Success
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)500 characters, hard-coded
OAuth scopeschat:write, user:read, channel:read
Redirect URI{FRONTEND_URL}/integrations/social/kick

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["KICK_CLIENT_ID", "KICK_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
KICK_CLIENT_IDYes—Client id from the Kick developer settings
KICK_SECRETYes—Client secret. The name is KICK_SECRET, not KICK_CLIENT_SECRET.
KICK_ID_BASE_URLNohttps://id.kick.comOAuth origin (/oauth/authorize, /oauth/token). Read through Config::base_url("KICK_ID", …).
KICK_BASE_URLNohttps://api.kick.comPublic API origin (/public/v1/...). Read through Config::base_url("KICK", …).
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI
JWT_SECRETNo direct useemptyNot read by the Kick flow. The OAuth state row stores the PKCE verifier and state without encryption for this provider. The rest of the server uses it for other secrets.

A very common configuration error is setting KICK_CLIENT_SECRET. The code only reads KICK_SECRET; rustiz providers --status will keep reporting kick as missing KICK_SECRET until the variable is renamed.

Step-by-Step Developer App Setup

1. Prepare the Kick account

  1. Sign in at https://kick.com.
  2. Enable two-factor authentication. Kick requires 2FA to access the developer tab.
  3. Review Kick’s Developer Terms and access policy at https://dev.kick.com (or the links in the developer settings).

2. Create the app

  1. Open Account Settings → Developer: https://kick.com/settings/developer.
  2. Create an app. Kick asks for:
    • an app name,
    • a Redirect URL (one URL per app in the current UI),
    • acceptance of the developer terms.
  3. Kick returns a Client ID and Client Secret. Copy both immediately.

The app is usable as soon as it is created. There is no separate “publish” or “submit for review” step for the chat API, and no product/API subscription to enable; the granted scopes are the only permission gate.

3. Set the environment and restart

KICK_CLIENT_ID="…"
KICK_SECRET="…"

Restart every Rustiz process. Check:

rustiz providers --status

4. Redirect URI (exact)

Kick::redirect_uri() is:

{FRONTEND_URL}/integrations/social/kick
  • FRONTEND_URL is trimmed of trailing slashes; register the URI without a trailing slash.
  • There is no redirectmeto.com wrapper for Kick.
  • Local example: http://localhost:5000/integrations/social/kick.
  • Kick’s docs note a 127.0.0.1 workaround for Next.js apps (where the framework rewrites the first 127.0.0.1 in a URL). Rustiz is not Next.js, so the workaround is not needed; 127.0.0.1 works if that is what you registered.
  • The same URI must be used in the authorize request and the token exchange.

There is a refresh quirk: authenticate() appends ?refresh=<internal-id> to the redirect URI when the connection was started with a refresh query parameter, while generate_auth_url() always sends the URI without it. Kick matches redirect URLs exactly, so a reconnect/refresh flow can fail with an invalid redirect_uri. Normal CLI/MCP connections do not set refresh.

5. Scopes requested and checked

Kick::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec!["chat:write", "user:read", "channel:read"];
}
ScopeUsed byWhy
chat:writePOST /public/v1/chatSend chat messages
user:readGET /public/v1/usersRead the channel’s user id, name and avatar during connect
channel:readNothing in this providerRead channel metadata; Rustiz never calls the channels API. It is an extra scope.

The authorize URL URL-encodes the space-joined list:

https://id.kick.com/oauth/authorize?response_type=code
  &client_id=…
  &redirect_uri=…
  &scope=chat%3Awrite%20user%3Aread%20channel%3Aread
  &state=…
  &code_challenge=…
  &code_challenge_method=S256

check_scopes() is never called. The token response’s scope is discarded; Rustiz never compares granted scopes with info.scopes. A user who denies chat:write still gets a “connected” channel; the first post fails.

channel:read is not used by the provider. Remove it from info.scopes if you want the minimal consent screen.

6. PKCE

Kick requires PKCE on every authorization-code exchange, even for confidential server clients.

  • Verifier: 64 random bytes from OsRng, base64url without padding (86 characters).
  • Challenge: SHA-256 of the verifier’s ASCII bytes, base64url without padding.
  • Method: S256.
  • The verifier is stored in the OAuth state row (codeVerifier, plaintext) and sent as code_verifier during the token exchange.
  • A new verifier/challenge is generated for every connect attempt. There is no reuse.

7. Sandbox / testing vs production

  • No sandbox. Kick has no test-mode app or separate test API. The app works against the production API immediately.
  • No app review for ordinary chat apps. App verification is optional: it adds a verified badge to the bot account and raises chat subscription limits from 1,000 to 10,000. To request it, email developers@kick.com with the client id, app name, whether the bot also needs verification, the reason and supporting evidence. It is not required to post chat messages.
  • Kick’s API docs have a “Test it” console on each endpoint. To use it with a user token:
    1. Set the app’s Redirect URL to https://docs.kick.com.
    2. Enter the client id and secret.
    3. Choose PKCE SHA-256.
    4. Select the scope the endpoint needs.
    5. Authorize and send the request. This is a docs playground, not a separate environment: requests hit production.
  • For tests, use a test Kick account/channel. Chat messages are visible to real viewers if the channel is live.
  • Production uses the same app and credentials. There is no review queue.

8. Token lifetime and refresh

  • The token response includes access_token, refresh_token, expires_in and scope. Rustiz stores the access token, refresh token and expires_in.
  • refresh_token() performs the refresh grant and calls /public/v1/users again to refresh the id/name/avatar.
  • Kick access tokens are short-lived; the exact lifetime is in expires_in. The engine refreshes when the stored expiry is near.
  • A 401 that is not handled by the provider is classified as a refresh error by the generic HTTP layer.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect kick --open
  • Prints the Kick consent URL; --open opens it.
  • With a local FRONTEND_URL, the CLI serves the callback and waits up to --timeout (default 300 s).
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll rustiz channels connect-status <id>.
  • No --external-url and no --fields: Kick is a normal OAuth provider. customFields takes precedence in connect.rs and would be treated as an authorization code, which fails.

MCP connectChannelTool

{ "provider": "kick" }

Returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://id.kick.com/oauth/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with:

{ "connectionId": "…" }

integrationSchema reports:

{ "type": "oauth", "headless": false, "requiresHuman": true, "requiresExternalUrl": false }

There is no triggerTool / channelSetupTool follow-up: the broadcaster is the authenticated user, so is_between_steps is false.

What gets stored

GET /public/v1/users returns a data value that may be an array or an object. user_fields() takes the first array element (or the object) and reads:

  • user_id, falling back to id → channel id (used as broadcaster_user_id)
  • name → name and username
  • profile_picture → picture

If the response is {} because the status was an error, all fields are empty but the access token is still saved. That channel fails at publish time with a null broadcaster id.

Post Settings & Media Validation

settings_schema()

Kick has no provider-specific settings:

{ "type": "object", "properties": {}, "additionalProperties": true }

__type is added by the engine. There is no message type, color, reply mode or channel selection.

validate_settings()

Not overridden; every settings object is accepted.

check_validity()

Not overridden. There are no pre-flight media or length checks beyond max_length.

Media requirements

Media is ignored. Kick chat cannot attach images or video. post() and comment() never read post.media; a post with images publishes as text only, with no warning. This is silent data loss.

How a post is built

POST {KICK_BASE_URL}/public/v1/chat
Authorization: Bearer <access token>
Content-Type: application/json

{
  "type": "user",
  "content": "<truncated text>",
  "broadcaster_user_id": <numeric user id>
}
  • type is always "user". Kick also supports "bot", but Rustiz does not use it.
  • broadcaster_user_id is id.trim().parse::<i64>(); a non-numeric channel id becomes JSON null, which Kick rejects with 400.
  • content is truncated to 500 Rust chars (Unicode scalar values) before sending.
  • Kick documents the limit as 500 user-perceived characters (grapheme clusters) and no more than 2,048 UTF-8 bytes. Rust’s chars().take(500) is neither: it can split a combining sequence, counts a family emoji as several characters, and can still emit more than 2,048 bytes for 500 four-byte characters.
  • The response is read as { data: { message_id, is_sent } } or a top-level { message_id, is_sent }. Missing message_id → random 10-char id.
  • is_sent == false → PostStatus::Success (the Rust enum has no error state), so a message dropped by Kick looks successful. The API’s message error text is discarded.

How a comment is built

Same endpoint, plus:

{ "reply_to_message_id": "<last_comment_id or post_id>" }
  • The reply target is last_comment_id when non-empty, otherwise the root post_id.
  • Kick replies are flat (reply_to_message_id), not a nested thread.
  • supports_comments = false, so the engine truncates threads before this code runs. Manual provider calls would work; MCP validation rejects thread replies.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

Implementation Notes & Gotchas

  • KICK_SECRET, not KICK_CLIENT_SECRET. This is the most common setup failure. Check required_env() in the Rust source; the variable is KICK_SECRET.
  • channel:read is unused. The provider only calls /public/v1/users and /public/v1/chat. Requesting channel metadata is unnecessary and expands the consent screen.
  • PKCE verifier length. 64 random bytes → 86 base64url characters, within RFC 7636’s 43–128 range. The challenge is S256, not plain.
  • Refresh redirect mismatch. A connection started with ?refresh= sends a token-exchange redirect_uri with the query string, while the authorize request used the bare URI. Kick’s exact matching can reject it. Normal connections are unaffected.
  • get_user_info() ignores the HTTP status. It reads the response body and parses it even on 401/403. A JSON error body becomes {}, so the channel can be saved with empty id/name and only fail at publish time. If a Kick channel has a blank name, check the token and the /public/v1/users response.
  • No PKCE on refresh. The refresh grant sends only grant_type, client_id, client_secret and refresh_token; Kick’s docs do not require a verifier or redirect URI for refresh.
  • No scope check. Granted scopes are never compared with the requested list. A denied chat:write is discovered only when posting fails.
  • No error classification. handle_errors() returns None; Kick’s { message } error bodies pass through the generic HTTP layer. A 401 becomes a refresh error, a 429 is retried up to three times.
  • is_sent: false is hidden. A message rejected by Kick (slow mode, banned words, channel restrictions, rate limit) returns 200 with is_sent: false and is reported as Success. Check the channel if a post claims success but does not appear.
  • The message id fallback is random. If Kick omits message_id, Rustiz generates a random id. That id cannot be used for replies or deletion. The same applies to post_id for a dropped message.
  • Media is silently dropped. No pre-flight warning. A post with images publishes as text.
  • 500-character limit mismatch. Kick’s current docs specify grapheme clusters plus a 2,048-byte ceiling. The Rust implementation uses chars().take(500) and the engine uses chars().count(). Expect edge cases with emoji, flags, skin tones and combining marks. For reliable results, keep posts well under 500 ASCII characters and avoid dense emoji.
  • broadcaster_user_id must be numeric. The internal id comes from get_user_info()’s user_id/id. If Kick returns a string UUID-like id, parse::<i64>() fails and the API receives null. Kick currently uses numeric user ids; this is a compatibility risk if that changes.
  • Release URL is the channel, not the message. https://kick.com/{profile} (or https://kick.com/channel if profile is empty). Kick chat messages have no stable public permalink.
  • No channel:read calls. There is no channel lookup, no “go live” and no stream metadata. The provider cannot verify that the channel exists or is live before posting.
  • Rate limits. Kick returns 429 with a message; the docs do not publish a single numeric chat limit. max_concurrent_job = 3 is a conservative cap. The generic HTTP layer retries 429/5xx up to three times.
  • No analytics or mentions. Both flags are false; there are no overrides.
  • No app verification needed. Verification only adds a badge and raises chat subscription limits. It is not an app review and does not gate posting.

References

Lemmy (lemmy)

Lemmy is a federated link-aggregator and discussion platform (a Reddit-style network of independent instances). Rustiz connects to one instance with a username and password, logs in again for every publish call, and can create a post in several communities at once. It also supports comments on the resulting posts.

  • Rust source: crates/rustiz-providers/src/lemmy.rs
  • Upstream port: lemmy.provider.ts
  • Editor: Normal (plain text after HTML sanitization)
  • Connection mode: customFields

Overview

PropertyValue
Identifierlemmy
Display nameLemmy
Connection modecustomFields (instance URL, identifier, password)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; each comma-separated post id gets a top-level comment
Pending / async publishingNo. supports_pending = false; returns PostStatus::Success
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)10000 characters for the post body
APILemmy HTTP API v3 (/api/v3/...)

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. The instance URL and account come from the channel, so rustiz providers --status reports lemmy as configured without server configuration.

VariableRequiredDefaultPurpose
JWT_SECRETNo, but should be set""Encrypts/decrypts the channel’s custom_instance_details (service, identifier, password). It must be the same value at connect time and on every later call. If it changes, decryption fails with Lemmy credentials are missing/invalid, please reconnect the channel. If it is unset consistently, an empty key is used (functional but insecure).
ALLOW_PRIVATE_NETWORKNofalseGlobal SSRF switch. A local test instance on localhost or a private IP is blocked unless this is true (or 1).

There is no LEMMY_BASE_URL override. The service field in the encrypted credentials is the base URL for every request.

Why JWT_SECRET matters

Connecting stores the credentials encrypted with fixed_encryption in crates/rustiz-server/src/oauth.rs. get_jwt_and_service() decrypts them on every post, comment and community search. The connect step itself succeeds without decrypting, so a secret that changed after connect can look like a successfully connected channel that fails on its first publish. Set a strong secret before connecting and never rotate it without reconnecting every Lemmy channel.

Step-by-Step Developer App Setup

Lemmy has no developer app, no OAuth client and no API-key portal. You use a normal account on an instance.

  1. Choose the Lemmy instance (for example https://lemmy.world) and create an account there, or use an existing account.

  2. Verify the account’s email if the instance requires it. Many instances hold new accounts in a registration-approval queue; wait until the account can log in through the web UI.

  3. Make sure the account can post in the target communities. A community may be restricted, the account may be banned from an instance, or the instance may block the URL in a link post. Rustiz maps these errors to specific messages.

  4. Do not enable two-factor authentication on the account. Rustiz’s login body sends only username_or_email and password; it has no totp_2fa_token field, so a 2FA-enabled account cannot authenticate.

  5. Use the username as the identifier, not the email address. The login endpoint accepts either, but the follow-up user lookup calls GET /api/v3/user?username={identifier}; an email in that query does not resolve to a user and the connect fails with Invalid credentials.

  6. Verify the login manually:

    curl -s -X POST https://lemmy.world/api/v3/user/login \
      -H 'Content-Type: application/json' \
      -d '{"username_or_email":"alice","password":"..."}'
    

    A successful response contains {"jwt":"..."}. Then:

    curl -s 'https://lemmy.world/api/v3/user?username=alice' \
      -H "Authorization: Bearer $JWT"
    

    Rustiz reads person_view.person.id, display_name/name and avatar from this response.

Products and permissions

  • No products or APIs to enable.
  • No OAuth scopes. The account’s normal instance permissions apply.
  • Posting to a community requires that the community exists, is not deleted or locked, and does not block the account or the post URL.
  • Comments require the post to still exist and not be locked.

Sandbox / testing mode

There is no hosted sandbox. Options:

  • Run a local Lemmy instance and set ALLOW_PRIVATE_NETWORK=true.
  • Use a low-traffic test community on a real instance.
  • Test with a text-only post first; a link post or image cover exercises more failure modes (blocked URL, missing thumbnail, federation delays).

Lemmy is federated: a post can succeed on the local instance but take time to appear on other instances. Rustiz returns as soon as the local API responds.

Production app review

There is no app review. Access is immediate and controlled by the instance’s registration policy and community moderation. The same credentials work in production.

API version compatibility

Rustiz hard-codes the v3 endpoints:

  • POST /api/v3/user/login
  • GET /api/v3/user?username=...
  • POST /api/v3/post
  • POST /api/v3/comment
  • GET /api/v3/search?type_=Communities&sort=Active&q=...

Lemmy 1.0 introduced API v4 and renamed account endpoints (for example /api/v3/user/login → /api/v4/account/auth/login). Lemmy’s 1.0 compatibility layer keeps v3 available for general browsing and commenting, so these calls should continue to work on 1.0 instances, but this provider does not use any v4-only feature and has not been updated for v4. If an instance disables v3, the channel cannot connect or publish.

Connecting a Channel (CLI & MCP)

The credential payload has three keys:

KeyTypeValidation regexNotes
servicetext/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/Instance base URL. Default value is https://lemmy.world. Must include the scheme and a host with a dot; a bare lemmy.world fails.
identifiertext/^.{3,}$/Username (preferred) or email. At least 3 characters.
passwordpassword/^.{3,}$/Account password. At least 3 characters in the form; the instance may require more.

CLI

{
  "service": "https://lemmy.world",
  "identifier": "alice",
  "password": "correct horse battery staple"
}
rustiz channels connect lemmy --fields lemmy.json

printf '%s' '{"service":"https://lemmy.world","identifier":"alice","password":"..."}' \
  | rustiz channels connect lemmy --fields -

--external-url is not the right flag for Lemmy. It invokes the externalUrl() OAuth-app-registration path, which Lemmy does not implement. Use --fields so the provider receives service.

Immediate result:

{ "connected": true, "pending": false, "name": "Alice" }

MCP

{
  "provider": "lemmy",
  "customFields": {
    "service": "https://lemmy.world",
    "identifier": "alice",
    "password": "correct horse battery staple"
  }
}

No follow-up step. Call integrationList for the channel id.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
subreddits{"word": "<keyword>"}[{"title": "<community title>", "name": "<community title>", "id": <numeric community id>}]Despite the name, searches Lemmy communities. Calls GET /api/v3/search?type_=Communities&sort=Active&q={word}. title and name are both the community title; the id is a JSON number.

MCP:

{
  "integrationId": "int_...",
  "methodName": "subreddits",
  "dataSchema": [{ "key": "word", "value": "rust" }]
}

The function logs in again using the stored credentials; the access_token argument is ignored.

Important type mismatch: subreddits returns id as a number, but settings_schema() and validate_settings() require subreddit[].value.id to be a string. If you take the tool output verbatim, validation fails with id is required and must be a string. Convert the id to a string ("7", not 7) before scheduling.

Also note that word is interpolated into the query string without URL encoding. A keyword with spaces or & can produce a broken URL; use a single word, or pre-encode it.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
subredditarrayYesHistorical name; it means Lemmy communities. minItems: 1; one post is created in each entry.
subreddit[].valueobjectYesRequired wrapper.
subreddit[].value.subredditstringYesCommunity name for display. minLength: 2. It is not used to resolve the community at publish time; id is.
subreddit[].value.idstringYesNumeric Lemmy community id, as a string. Get it from the subreddits tool and stringify it.
subreddit[].value.titlestringYesTitle of the post in that community. minLength: 2; each community can have a different title.
subreddit[].value.urlstringNoOptional link-post URL. A bare domain gets https:// prepended; an http(s) URL is sent as-is.

Example object form:

{
  "subreddit": [
    {
      "value": {
        "subreddit": "rust",
        "id": "7",
        "title": "Rust news",
        "url": "https://example.com/article"
      }
    },
    {
      "value": {
        "subreddit": "lemmy",
        "id": "8",
        "title": "Lemmy news"
      }
    }
  ]
}

MCP settings array:

{
  "settings": [
    {
      "key": "subreddit",
      "value": [
        {
          "value": {
            "subreddit": "rust",
            "id": "7",
            "title": "Rust news",
            "url": "https://example.com/article"
          }
        }
      ]
    }
  ]
}

The engine adds "__type": "lemmy" automatically if missing.

validate_settings() rules

  1. subreddit must exist and be an array with at least one item.
  2. For each item at index i:
    • value must exist;
    • value.subreddit must be a string of at least 2 characters;
    • value.id must be a string (numbers fail);
    • value.title must be a string of at least 2 characters;
    • value.url, if present and a string, must be a valid http(s) URL with a host when non-empty.

Publish behavior

For each subreddit[].value, post() calls POST /api/v3/post with:

{
  "community_id": 7,
  "name": "Rust news",
  "body": "<post message>",
  "url": "https://example.com/article",
  "custom_thumbnail": "<first media path>",
  "nsfw": false
}
  • community_id is converted from a numeric string (or number) to a JSON number. A non-numeric id becomes null, and Lemmy rejects the request.
  • url is included only when non-empty. A value that does not contain "http" gets https:// prepended.
  • custom_thumbnail is the first media item’s path, regardless of its type. It must be a URL the Lemmy instance can fetch; local Rustiz paths are not uploaded to Lemmy.
  • nsfw is always false; there is no setting to mark a post NSFW.
  • The response ids and URLs are comma-joined: post_id = "100,101", release_url = "https://instance/post/100,https://instance/post/101".
  • Status is PostStatus::Success (not Completed).

Comments

comment() is supported and is how threads/replies are published:

  • post_id may be a comma-separated list from a cross-community post.
  • For each id, it calls POST /api/v3/comment with {"post_id": <number>, "content": "<comment message>"}.
  • Comment ids and URLs are comma-joined.
  • last_comment_id is ignored: every comment is a top-level comment, not a reply to the previous one.
  • Comment media and settings are ignored; only the text is sent.
  • supports_comments = true makes this provider usable for multi-part “threads”, but each part is a separate top-level comment rather than a nested chain.

Media requirements

  • check_validity() looks only at the first media item of the first post:
    • the path must contain png, jpg, jpef (sic) or gif;
    • otherwise it returns You can set only one picture for a cover.
  • Because the check is a case-sensitive substring test:
    • .jpeg, .webp, .PNG, .JPG are rejected even though they are valid images;
    • .jpeg fails because the code checks the typo jpef, not jpeg;
    • a path with the letters in a query string can pass accidentally.
  • It does not enforce a single item: two .jpg files pass and only the first is used as custom_thumbnail.
  • Videos (.mp4) are rejected as a cover.
  • There is no upload step. The cover/thumbnail must be a public URL the instance can reach.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • JWT_SECRET is an undeclared hard dependency. required_env() is empty, but decryption fails without the same secret used at connect time. This is the most common cause of a Lemmy channel that connected but cannot post.
  • Login happens on every publish. post(), comment() and subreddits() call get_jwt_and_service(), which decrypts the password and performs a fresh /api/v3/user/login. The JWT stored during authenticate() is effectively unused (the _access_token argument is ignored). This means a password change breaks posting immediately, but a short-lived JWT does not.
  • The password is in the request body of every login. The code deliberately strips the request body from provider errors (without_request_body) so the password is not stored in the engine’s error record. Keep that behavior if you refactor.
  • Use the username, not the email. Login accepts username_or_email, but the subsequent GET /api/v3/user?username={identifier} does not resolve an email. A user who enters an email can get Invalid credentials even with the right password.
  • 2FA is unsupported. There is no totp_2fa_token in the login payload. Disable 2FA on the account or use a dedicated account.
  • API v3 is hard-coded. Lemmy 1.0’s v3 compatibility layer currently covers browsing and commenting, but new v4-only features are not available. A future removal of v3 would require a provider update.
  • The community id type is inconsistent. The subreddits tool returns a numeric id; validation requires a string. This is a real round-trip bug, not a documentation nuance.
  • subreddits returns title twice. title and name are both community.title, so the tool cannot show the community’s handle (!rust@lemmy.ml) separately. Use the id for the actual post.
  • Search is not URL-encoded. q={word} is concatenated directly. A space or & can break the request; use a single keyword.
  • Only the first attachment is used. Extra images are silently ignored (unlike Tumblr/Dribbble, no media-count error is raised). The first path is sent as custom_thumbnail and is not uploaded.
  • nsfw is always false. There is no NSFW setting; posting NSFW content to a community that requires the flag will be rejected or moderated.
  • Comments are flat. last_comment_id is ignored, so a multi-part thread becomes several top-level comments rather than replies to one another.
  • Status is Success, not Completed. This differs from the article providers and Dribbble. The engine treats Success as a published state; it is not a pending response.
  • Error mapping is instance-specific. rate_limit_error retries, not_logged_in/incorrect_login requests a refresh (which cannot help because there is no refresh token; reconnect), and bans, missing communities, blocked URLs, deleted posts and locked posts become actionable bad_body messages.
  • No refresh. refresh_token() returns an empty token; credentials never expire, and posting re-logs in anyway. Reconnect after a password change.

References

LinkedIn (linkedin)

LinkedIn publishes as the authenticated member (urn:li:person:<sub>) through the LinkedIn Posts API. Publishing is split into a pending phase (upload media, wait until LinkedIn reports every upload AVAILABLE) and a final create that runs through an arm → confirm → publish handshake, because the Posts API has no idempotency key: an unknown-outcome create must never be retried blindly.

The organization flavour of the same code is linkedin-page. Both providers share one OAuth app, one redirect-URI shape, and the same seven scopes.

  • Rust source: crates/rustiz-providers/src/linkedin.rs
  • Upstream port: linkedin.provider.ts
  • Editor: Normal (plain text; LinkedIn little-text escaping is applied)
  • Connection mode: oauth

Read the gotchas first. The provider requests r_basicprofile and the organization scopes, which only the Community Management API (or the Advertising API, the product named by the Postiz docs) grant. If you only add the self-serve products, the connection fails with not_enough_scopes or LinkedIn rejects the authorization with invalid scope. See Implementation Notes & Gotchas.

Overview

PropertyValue
Identifierlinkedin
Display nameLinkedIn
Connection modeoauth (authorization code, no PKCE)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; replies are posted as comments on the parent post and may only contain text
Pending / async publishingYes. supports_pending = true; post_pending → check_post_status → finalize_post
AnalyticsNo for the member provider (supports_analytics = false; analytics() returns Unsupported). Page analytics live in linkedin-page
Post analyticsNo (supports_post_analytics = false)
MentionsYes, company pages only. supports_mentions = true, mention_format emits @[Name](urn:li:organization:<id>)
is_between_stepsfalse
convert_to_jpegfalse (media is re-encoded inside prepare_media_buffer, not by the engine)
max_concurrent_job8
Character limit (max_length)3000 characters
APILinkedIn REST Posts API, LinkedIn-Version: 202601 (comments use 202306)
OAuth scopesopenid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social
Token lifetime60-day access token; programmatic refresh token only if LinkedIn enabled it for the app

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden. provider_status refuses to generate a login URL when either value is empty.

VariableRequiredDefaultPurpose
LINKEDIN_CLIENT_IDYesnoneLinkedIn app Client ID (Auth tab).
LINKEDIN_CLIENT_SECRETYesnoneLinkedIn app Client Secret (Auth tab).
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the OAuth redirect URI. Trailing slashes are stripped by Config::frontend_url().
LINKEDIN_BASE_URLNohttps://api.linkedin.comOverrides the REST API origin. Trailing slash stripped.
LINKEDIN_OAUTH_BASE_URLNohttps://www.linkedin.comOverrides the authorize/token origin. Paths appended are /oauth/v2/authorization and /oauth/v2/accessToken.

There is no LINKEDIN_PAGE_* variable set: the page provider reads the same two credentials.

Check the deployment with:

rustiz providers --status | jq '.[] | select(.identifier=="linkedin")'

Step-by-Step Developer App Setup

LinkedIn has no “sandbox app” switch. You create one app and request products; the Marketing API products then start in a Development access tier.

  1. Create the app. Open https://www.linkedin.com/developers/apps/new and sign in with the LinkedIn account that administers the company page.

    • App name, LinkedIn Page (required: associate a page you administer), App logo, Legal agreement.
    • A verified page/company is required before LinkedIn will grant the organization products.
  2. Verify the app if the Products tab asks for it (business email verification and page association).

  3. Add the products under Products:

    • Sign In with LinkedIn using OpenID Connect — grants openid, profile (and email, which Rustiz does not request).
    • Share on LinkedIn — grants w_member_social (self-serve, instant).
    • Community Management API — grants r_basicprofile, r_organization_social, rw_organization_admin, w_organization_social. This is the product that makes the full scope set valid. Apply and wait for approval; the app starts in the Development tier.
    • Advertising API is the alternative named in the Postiz guide. Its permission table also includes r_basicprofile, r_organization_social, rw_organization_admin and w_organization_social. Postiz’s docs explicitly recommend requesting it because it is also the path to programmatic refresh tokens.
    • r_basicprofile is a legacy Marketing API permission. The self-serve “Sign In with LinkedIn using OpenID Connect” product grants profile, not r_basicprofile, so adding only the two self-serve products is not enough.
  4. Confirm the granted scopes. Open the app’s Auth tab and check the “OAuth 2.0 scopes” list contains all seven values below. This list, not the requested URL, decides what the token will carry.

  5. Add the redirect URI under Auth → OAuth 2.0 settings → Authorized redirect URLs:

    {FRONTEND_URL}/integrations/social/linkedin
    

    Example: https://social.example.com/integrations/social/linkedin. No trailing slash. LinkedIn requires an absolute HTTPS URL; http://localhost is commonly accepted for development but is not what LinkedIn documents. Query parameters are ignored; a fragment (#) is invalid — register the bare path.

  6. Copy the credentials from the Auth tab and set them:

    LINKEDIN_CLIENT_ID="your-client-id"
    LINKEDIN_CLIENT_SECRET="your-client-secret"
    FRONTEND_URL="https://social.example.com"
    
  7. Restart Rustiz and run the status check above. Then connect a channel.

Exact OAuth scopes

Linkedin::scopes() returns, in this order:

#![allow(unused)]
fn main() {
vec![
    "openid",
    "profile",
    "w_member_social",
    "r_basicprofile",
    "rw_organization_admin",
    "w_organization_social",
    "r_organization_social",
]
}

The authorization URL joins them with %20 (URL-encoded spaces); the token response is checked by check_scopes(&self.info.scopes, token["scope"]). check_scopes accepts either a space- or comma-separated scope value and requires every requested scope. A missing scope produces ProviderError::NotEnoughScopes and the connection is refused; LinkedIn does not fail the login itself when a scope was not granted.

Which product grants what:

ScopeGranted by
openid, profileSign In with LinkedIn using OpenID Connect
w_member_socialShare on LinkedIn (also Community Management / Advertising API)
r_basicprofileCommunity Management API or Advertising API
rw_organization_adminCommunity Management API or Advertising API
w_organization_socialCommunity Management API or Advertising API
r_organization_socialCommunity Management API or Advertising API

The organization scopes are requested by the personal provider too, not just by linkedin-page. Both providers share the app and the scope set; this is why the same app can connect a member and then a page.

Redirect URI details

redirect_uri() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/{}", self.ctx.config.frontend_url(), self.info.identifier)
}
  • frontend_url() trims all trailing /, so FRONTEND_URL=https://x/ still yields https://x/integrations/social/linkedin.
  • There is no redirectmeto.com relay (unlike TikTok): the value is used verbatim. LinkedIn wants HTTPS for anything non-local.
  • Personal provider, reconnect only: if a refresh id is supplied to GET /public/v1/social/linkedin?refresh=<internalId>, the token exchange appends ?refresh=<id> to the redirect URI (.../linkedin?refresh=r-1). The authorization URL itself never contains it. The CLI and connectChannelTool do not pass refresh, so this only matters for a client that calls the reconnect endpoint directly. If the exchange ever fails with invalid_redirect_uri, this is the first thing to inspect.
  • The page provider never appends ?refresh (its authenticate forces the plain URI), which is asserted by the port’s tests.

Both providers need their own URL registered if you use both:

https://social.example.com/integrations/social/linkedin
https://social.example.com/integrations/social/linkedin-page

Sandbox / testing mode

LinkedIn does not offer a separate sandbox host for these APIs. Instead:

  • Marketing API Development tier is the testing mode. Community Management Development is limited to 500 API calls per app per 24 h and 100 API calls per member per 24 h; BATCH_GET is disabled and Social Actions webhooks are not pushed. Plan a connect-and-post test well inside those numbers.
  • Use your own LinkedIn account plus a company page you administer. pages() / companies() only return organizations where the member has ADMINISTRATOR or CONTENT_ADMINISTRATOR.
  • There is no per-app “test user list” to add: any member who can log in and has the right page role can authorize the app.
  • Share on LinkedIn (member posting) is self-serve and works immediately after the product is added. Organization posting only works once the Community Management (or Advertising) product is approved.

Production app review

  • Community Management / Advertising APIs are gated products: apply through the Developer Portal, then request the Standard tier for production. Development is time-boxed (Community Management expects you to finish within 12 months) and call-limited; Standard removes the restrictions.
  • LinkedIn may require app verification (page association, business email, logo) before granting organization products.
  • If a product is not approved, the OAuth request fails with invalid scope (or returns a token without the scope and Rustiz reports not_enough_scopes).
  • Programmatic refresh tokens are only issued to approved Marketing API partners. Without one, the 60-day access token simply expires and the channel must be reconnected. Postiz’s guide calls this out as the reason to request the Advertising API.

Connecting a Channel (CLI & MCP)

CLI

# Open the LinkedIn consent page in the default browser.
rustiz channels connect linkedin --open

# Print the URL to stderr and wait for the callback in-process.
rustiz channels connect linkedin

# Remote server: open the printed URL yourself, then poll.
rustiz channels connect-status <connectionId>

Do not pass --fields or --external-url for this provider: any customFields object switches the shared connect path to the credential (customFields) branch, and externalUrl is unsupported for LinkedIn. Both produce a failed authenticate instead of an OAuth login.

In local/in-process mode the CLI serves the callback on the port of FRONTEND_URL when that is http://localhost / http://127.0.0.1, and waits up to --timeout seconds (default 300) for the new channel. It prints the channel JSON on success.

MCP

Start the flow:

{ "provider": "linkedin" }

The result is either a URL elicitation (for clients that support it) or:

{
  "connected": false,
  "pending": true,
  "connectionId": "abc123",
  "url": "https://www.linkedin.com/oauth/v2/authorization?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with the same tool:

{ "connectionId": "abc123" }

linkedin is not is_between_steps, so there is no channelSetupTool follow-up. When polling reports connected, call integrationList to get the channel id.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
companyurl — a company page URL such as https://www.linkedin.com/company/acme[name, "@[Name](urn:li:organization:<id>)"] or nullResolves the vanity name and returns the exact tag value to paste into the post text.
mentionquery — the exact vanity name (the part after linkedin.com/company/)array of {id, label, image, doNotCache}Searches /v2/organizations?q=vanityName. Only organization pages, never members.

The mention format is also applied automatically when a post carries a mention id/label (mention_format). escape_part preserves @[Name](urn:li:organization:<id>) and escapes every other @.

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredBehavior
post_as_images_carouselbooleanNoIf true, the first post’s images are combined into one PDF document and uploaded as a LinkedIn document carousel. Requires at least 2 images and no video. Default: off.
carousel_namestringNoTitle shown on the PDF document. Used only when post_as_images_carousel is true; empty/missing becomes "slides".

additionalProperties: true. There is no visibility, link-preview, or reshare setting: every post is sent with visibility: "PUBLIC", feedDistribution: "MAIN_FEED" and lifecycleState: "PUBLISHED".

validate_settings() rules

  • If settings is not a JSON object, validation returns Ok(()): the DTO path only checks fields that exist. Send an object in practice.
  • post_as_images_carousel, if present and non-null, must be a boolean.
  • carousel_name, if present and non-null, must be a string.
  • Nothing else is validated here; media rules are in check_validity.

check_validity() rules

posts[0] is the main post; posts[1..] are thread replies/comments.

  • With post_as_images_carousel = true: the first post must have ≥ 2 media and no path containing mp4, otherwise: "Carousel can only be created with 2 or more images and no videos."
  • More than one media item with any mp4 in the first post: "Can have maximum 1 media when selecting a video."
  • Any media on a comment post: "Comments can only contain text."
  • Empty media is allowed (text-only posts).

Media and publish behavior

  • Images are read through the media store, then:
    • GIF passes through untouched.
    • PNG stays PNG.
    • JPEG stays JPEG.
    • Anything else is decoded and re-encoded as JPEG (quality 80).
    • Images are fitted inside 6000×6000 without enlargement (LinkedIn’s documented image cap is < 36,152,320 pixels; JPG/GIF/PNG).
  • Video is any path containing .mp4. It is uploaded in 2 MiB byte ranges to the uploadInstructions[0].uploadUrl, collecting the etag of every part, then POST /rest/videos?action=finalizeUpload with uploadedPartIds and an empty uploadToken. Initialization sends fileSizeBytes, uploadCaptions: false, uploadThumbnail: false.
  • PDF carousel: convert_images_to_pdf_carousel re-encodes every image to JPEG, uses the largest image’s dimensions as the PDF page size, centers every smaller image, and generates a minimal PDF (%PDF-1.4, one page per image, /DCTDecode JPEG streams). The resulting PDF is uploaded through the documents endpoint with Content-Type: application/pdf. The first post is rewritten to reference one synthetic carousel.pdf; the original comments are kept.
  • Only the first media of a post is used for video (validation enforces one); multi-image posts use content.multiImage.
  • Comment media is never published (validation rejects it).

Publish handshake

  1. post_pending uploads all media and returns PostResponse::pending with: authorId, postType (personal), message, isPdf, pdfTitle, mediaIds, poll (urns still processing), graceChecks.
  2. check_post_status polls each urn:
    • non-AVAILABLE → still processing;
    • PROCESSING_FAILED → BadBody with the failure reason;
    • a status-call error is tolerated up to 10 consecutive stalls;
    • once everything is available, if attempting && !confirmed it flips confirmed = true and returns Ready without creating the post.
  3. finalize_post:
    • if not attempting/confirmed, sets attempting = true, confirmed = false and returns Pending (the “arm” step);
    • on the next pass (attempting && confirmed) it creates the post.
  4. If attempting && confirmed is observed again in check_post_status, the provider refuses with: “LinkedIn may have already published this post, please check your account before posting again to avoid duplicates”. This is the duplicate guard for a crashed create.
  5. post() (the blocking path) loops for up to 8 minutes, sleeping 20 s between checks, then errors with “LinkedIn took too long to process the media, please try again”.

Plugs

The personal provider:

  • run_plug() → Unsupported for every method. autoRepostPost and autoPlugPost are not available on personal accounts.
  • run_post_plug() supports:
    • addComment — posts information["comment"] as a comment using the integration’s own token/actor (urn:li:person:<internalId>).
    • repostPostUsers — reshares post_id with reshareContext.parent, authored as the integration’s person urn and with empty commentary.

The page provider has the mirror image (see linkedin-page.md): it supports the two run_plug automations but rejects run_post_plug.

Implementation Notes & Gotchas

  • prompt=none is sent on the authorization URL. LinkedIn’s documented parameters are only response_type, client_id, redirect_uri, state and scope; prompt is a port artifact. It is harmless today, but if LinkedIn starts validating unknown parameters it can break the login.
  • No PKCE. generate_auth_url returns a code_verifier and the state is random, but the authorization URL has no code_challenge and the token request sends client_secret. Treat the flow as classic confidential-client OAuth.
  • The token response’s scope is authoritative. If the app is missing a product, the login can appear to succeed and then fail with not_enough_scopes after the code exchange. Check the Auth tab before debugging anything else.
  • 60-day access tokens; programmatic refresh is partner-only. refresh_token() posts grant_type=refresh_token to /oauth/v2/accessToken with the app credentials. If LinkedIn did not issue a refresh token, Rustiz stores None, the token eventually returns 401, the generic HTTP layer classifies 401 as RefreshToken, the engine tries to refresh with an empty token, fails, marks the channel refreshNeeded and notifies the org. This is exactly why the Postiz guide insists on the Advertising API form.
  • Token refresh is reactive, not scheduled. refresh_cron is false. The only proactive refresh in the engine applies to providers with refresh_cron = true; LinkedIn is refreshed when an API call produces a 401/RefreshToken (publish, analytics, trigger).
  • one_time_token = true and refresh_wait = true are declared but unused by the Rust engine. They are upstream metadata; do not expect special scheduling.
  • Hard-coded API versions. Posts, media, organizations, analytics and social actions send LinkedIn-Version: 202601. Comments send 202306, with the code comment “the comment endpoint still answers to the old version”. LinkedIn supports each version for roughly a year (202601 is scheduled to sunset around January 2027; 202306 is already far past its normal window). If comments start returning 426/400, this header is the cause.
  • Comment URLs are different. Main posts produce https://www.linkedin.com/feed/update/<id>; comments produce https://www.linkedin.com/embed/feed/update/<id>.
  • Text is escaped, not markdown. fix_text escapes \ < > # ~ _ | [ ] * ( ) { } @ outside company mentions. Company mentions written as @[Name](urn:li:organization:<id>) are preserved. This means #hashtag becomes \#hashtag; that is intentional little-text escaping, not a bug.
  • Mentions are organizations only. mention() calls /v2/organizations?q=vanityName&vanityName=..., so it can tag company pages, never individual members. supports_mentions = true means “company mentions”.
  • r_basicprofile does not grant member search. Even with the scope, the provider only reads sub, name, picture and vanityName.
  • Personal analytics really are unsupported. analytics() and post_analytics() return Unsupported when page == false. Use linkedin-page for organization metrics.
  • Signed media URLs can change the format. mime_lookup takes the extension after the last . of the final path segment including the query string. photo.png?token=... is therefore not recognized as PNG and is re-encoded as JPEG (losing transparency); a GIF behind a signed URL loses animation. Plain /uploads/photo.png paths are unaffected. has_extension (used for .mp4/.pdf) does a substring match, so videos and PDFs with query strings are still detected.
  • Media polling can be slow. Video/document processing is polled every 20 s for up to 8 minutes in the blocking path; the durable engine path can wait longer because it re-schedules. LinkedIn also occasionally returns 403 “resource is forbidden” or 503 “Service Unavailable” during processing; both are mapped to retry.
  • Video chunk size is 2 MiB. LinkedIn’s examples commonly use 4 MiB parts. Very large videos are worth testing end-to-end; the port is faithful to the upstream implementation rather than LinkedIn’s sample size.
  • Rate limits. Community Management Development is 500 calls/app/24 h plus 100 calls/member/24 h; Standard has no such restriction. Share on LinkedIn additionally throttles member posting (third-party write-ups commonly cite ~150 requests/member/day and 100,000/app/day, but LinkedIn does not publish that number in the OAuth guide). The generic HTTP layer retries 429, unclassified 500 and retry-classified responses twice (three attempts total, 5 s apart); there is no provider-specific rate-limit message.

References

LinkedIn Page (linkedin-page)

linkedin-page publishes as a company page (urn:li:organization:<organizationId>). It is the same Rust implementation as linkedin with three differences: the identifier, the author type (company), and organization analytics. The provider is is_between_steps = true: the OAuth login creates a placeholder channel for the signed-in member, then channelSetupTool replaces it with the chosen organization.

  • Rust source: crates/rustiz-providers/src/linkedin_page.rs (wraps crates/rustiz-providers/src/linkedin.rs)
  • Upstream port: linkedin.page.provider.ts
  • Editor: Normal
  • Connection mode: oauth + a mandatory page-selection step

Both providers use the same app and the same seven scopes. You need the Community Management API (or Advertising API) product even to list the pages you administer. Register a second redirect URI for linkedin-page.

Overview

PropertyValue
Identifierlinkedin-page
Display nameLinkedIn Page
Connection modeoauth, then channelSetupTool (page selection)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; comments are authored as the organization
Pending / async publishingYes. supports_pending = true; same media → arm/confirm → create flow
AnalyticsYes. supports_analytics = true; organization page, follower and share statistics
Post analyticsYes. supports_post_analytics = true; per-share statistics with a social-actions fallback
MentionsYes, company pages only. supports_mentions = true
is_between_stepstrue — a channel is unusable until a page is selected
convert_to_jpegfalse
max_concurrent_job2 declared. The engine’s queue key is linkedin for both LinkedIn providers and takes the max of their limits, so the effective LinkedIn queue has 8 permits.
Character limit (max_length)3000 characters
APILinkedIn REST Posts API, LinkedIn-Version: 202601 (comments 202306)
OAuth scopesopenid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social
Token lifetime60-day access token; partner-only programmatic refresh

Server Environment Variables

required_env() is exactly the same as the personal provider:

#![allow(unused)]
fn main() {
&["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
LINKEDIN_CLIENT_IDYesnoneSame app as linkedin.
LINKEDIN_CLIENT_SECRETYesnoneSame app as linkedin.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI. Trailing slashes stripped.
LINKEDIN_BASE_URLNohttps://api.linkedin.comREST API origin.
LINKEDIN_OAUTH_BASE_URLNohttps://www.linkedin.comAuthorize/token origin.

There are no page-specific variables: the page provider reuses the linkedin app’s LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET.

Step-by-Step Developer App Setup

Use one LinkedIn app for both linkedin and linkedin-page.

  1. Create the app at https://www.linkedin.com/developers/apps/new and associate the company page you administer.

  2. Add products under Products:

    • Sign In with LinkedIn using OpenID Connect → openid, profile.
    • Share on LinkedIn → w_member_social.
    • Community Management API → r_basicprofile, r_organization_social, rw_organization_admin, w_organization_social. The Advertising API grants the same organization set and is the product the Postiz guide tells you to request.
    • Without a Marketing API product, companies() cannot work: the ACL endpoint requires rw_organization_admin, and posting requires w_organization_social.
  3. On the Auth tab, verify the OAuth 2.0 scopes list has all seven scopes from linkedin.md.

  4. Add both authorized redirect URLs:

    https://social.example.com/integrations/social/linkedin
    https://social.example.com/integrations/social/linkedin-page
    

    The page path is {FRONTEND_URL}/integrations/social/linkedin-page. No trailing slash. The code appends ?refresh= only for the personal provider, never for the page provider.

  5. Set LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET and FRONTEND_URL, restart, and confirm:

    rustiz providers --status | jq '.[] | select(.identifier=="linkedin-page")'
    
  6. Connect the channel, then select a page (next section). A freshly connected channel has inBetweenSteps=true and cannot publish.

Exact OAuth scopes

Identical Linkedin::scopes() set:

openid profile w_member_social r_basicprofile rw_organization_admin w_organization_social r_organization_social

check_scopes runs on the token response’s scope and requires every value. The page provider passes its own identifier into details_from_token, so the initial channel id is prefixed linkedin-page_<memberSub>; that placeholder is replaced during page selection.

Redirect URI details

{FRONTEND_URL}/integrations/social/linkedin-page
  • FRONTEND_URL has trailing / removed before concatenation.
  • No redirectmeto.com; the literal URI is sent to LinkedIn at authorize time and again at token exchange, byte-for-byte.
  • authenticate() explicitly skips the ?refresh= suffix for the page provider, even when a refresh id was stored. The port’s test authenticate_prefixes_id_and_never_adds_refresh_to_redirect asserts this.

Sandbox / testing mode

  • No separate sandbox. The Community Management Development tier is the testing mode: 500 API calls per app per 24 h, 100 per member per 24 h, no BATCH_GET, no Social Actions webhooks.
  • The signed-in member must be ADMINISTRATOR or CONTENT_ADMINISTRATOR on the target page. companies() filters out every other role.
  • App verification / page association may be required before the organization products are granted.
  • Test with a page you own; there is no “test page” construct.

Production app review

  • Apply for Community Management API (or Advertising API) and, for live traffic, the Standard tier. Development is restricted and time-boxed.
  • Standard removes the call limits and enables BATCH_GET / webhooks.
  • Approval is discretionary; LinkedIn can refuse or delay. Until then the page can still be connected on the Development tier, subject to the daily caps.
  • Refresh tokens are only issued to approved Marketing API partners. Without one, the 60-day access token expires and the page must be reconnected.

Connecting a Channel (CLI & MCP)

CLI

# 1. OAuth login. Creates an in-between-steps placeholder channel.
rustiz channels connect linkedin-page --open

# Remote server: the login URL is printed; poll with:
rustiz channels connect-status <connectionId>

# 2. The CLI has no page-selection command. Finish with MCP
#    channelSetupTool (or the equivalent API call), then list:
rustiz channels list

Do not pass --fields: it switches the shared connect path to the customFields branch, which this provider does not implement.

The CLI’s in-process callback server only activates when FRONTEND_URL is http://localhost or http://127.0.0.1; otherwise a remote rustiz serve handles the callback.

MCP

Step 1 — start OAuth:

{ "provider": "linkedin-page" }

Returns connectionId and url (or a URL elicitation). A human opens the URL.

Step 2 — poll until connected:

{ "connectionId": "abc123" }

The channel now exists with inBetweenSteps = true. It cannot publish yet.

Step 3 — list the pages the member administers:

{
  "integrationId": "int_...",
  "methodName": "companies",
  "dataSchema": []
}

companies calls:

GET /v2/organizationalEntityAcls?q=roleAssignee&state=APPROVED
    &projection=(elements*(role,organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))

and returns only ADMINISTRATOR / CONTENT_ADMINISTRATOR entries:

[
  {
    "id": "111",
    "page": "111",
    "username": "acme",
    "name": "Acme",
    "picture": "https://media.example/logo.png"
  }
]

Step 4 — choose the page:

{
  "integrationId": "int_...",
  "data": { "page": "111" }
}

fetch_page_information reads data["page"] (not id), then calls:

GET /v2/organizations/111?projection=(id,localizedName,vanityName,logoV2(original~:playableStreams))

channelSetupTool writes the organization id/name/logo/vanity name into the integration, sets inBetweenSteps = false, and returns { "success": true, "name": "Acme" }. Selecting a page that is already connected as another channel fails with That page is already connected as another channel.

Never skip step 3/4. A placeholder channel has the member’s id (linkedin-page_<sub>) and would post as urn:li:organization:<memberSub> — the Posts API rejects it.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
companiesnonearray of {id, page, username, name, picture}Only pages where the member is ADMINISTRATOR or CONTENT_ADMINISTRATOR; pages with a null organizationalTarget~ are skipped.

The personal provider’s company / mention helpers are declared on the shared Linkedin struct, but LinkedinPage::functions() exposes only companies. Use triggerTool with methodName: "companies".

Post Settings & Media Validation

LinkedinPage delegates settings_schema(), validate_settings() and check_validity() to Linkedin, so the rules are identical to the personal provider:

FieldTypeRequiredBehavior
post_as_images_carouselbooleanNoCombine the first post’s images into a PDF document carousel. Needs ≥ 2 images, no video.
carousel_namestringNoPDF document title; defaults to "slides".

validate_settings(): boolean/string type checks only.

check_validity():

  • carousel + fewer than 2 images or any video → "Carousel can only be created with 2 or more images and no videos."
  • more than one media with a video → "Can have maximum 1 media when selecting a video."
  • media on a comment post → "Comments can only contain text."

Media handling, PDF generation, the upload endpoints and the arm/confirm create handshake are all in linkedin.md. The only page-specific differences are:

  • post_payload uses author = urn:li:organization:<internalId>.
  • upload_picture uses the organization owner for initialize-upload requests.
  • Company images and documents are polled through /rest/images/<urn> and /rest/documents/<urn> until AVAILABLE; personal images/documents only get a one-cycle grace check.
  • Comments use actor = urn:li:organization:<internalId> and the LinkedIn-Version: 202306 header.
  • The release URL is still https://www.linkedin.com/feed/update/<postId>.

Plugs

The page provider supports the run_plug automations (the opposite of the personal provider):

MethodFieldsBehavior
autoRepostPostlikesAmountReads likesSummary.totalLikes from /v2/socialActions/<postId>; if >= likesAmount, waits 2 s and creates a reshare with reshareContext.parent = postId, authored by the page. Returns true when it reposted.
autoPlugPostlikesAmount, postSame threshold; posts post as a comment on postId with actor = urn:li:organization:<internalId>. Returns true when it commented.

run_post_plug() returns Unsupported for every method, so addComment / repostPostUsers plugs cannot target a page through this provider.

The plug fields are read leniently (plug_text): strings, numbers and booleans are all stringified; a missing likesAmount parses to 0, so the threshold is always met.

Implementation Notes & Gotchas

  • The page id is not the member id. The initial OAuth channel id is linkedin-page_<memberSub>; channelSetupTool replaces it with the numeric organization id. Forgetting the setup step produces a channel that fails at publish time with an invalid organization urn.
  • Page selection is a separate privilege. companies() needs rw_organization_admin; page_information() additionally reads the organization’s localizedName, vanityName and logoV2. A member with only w_organization_social can post but cannot list/select pages.
  • Role filter is strict. Only ADMINISTRATOR and CONTENT_ADMINISTRATOR are returned. ANALYST, RECRUITER, LEAD_GENERATION and similar roles are invisible even though they may see the page in LinkedIn.
  • Analytics has a fixed percentage_change = 5.0. page_analytics always reports a 5.0% change regardless of the data — a port artifact, not a computed delta. Labels are always returned in this order: Page Views, Clicks, Shares, Engagement, Comments, Organic Followers, Paid Followers.
  • Page analytics needs three endpoints. It queries organizationPageStatistics, organizationalEntityFollowerStatistics and organizationalEntityShareStatistics with a daily time range. If one product scope is missing, the whole call fails rather than returning partial data.
  • Post analytics falls back to social actions. It queries organizationalEntityShareStatistics?...&shares=List(<postId>); if every label is empty it queries /v2/socialActions/<postId> and emits only Likes and Comments for today. Empty labels are filtered out.
  • post_analytics() returns labels lazily. Unlike page_analytics, it drops empty series, so a post with no data returns [] rather than zeroed series.
  • Plugs are inverted between the two providers. Page: run_plug works, run_post_plug does not. Personal: run_post_plug works, run_plug does not. This is faithful to upstream, but surprising.
  • autoRepostPost and autoPlugPost take a raw post id. They do not normalize a LinkedIn URN or URL; pass the id the provider returned (releaseId, e.g. urn:li:share:...).
  • The 2-permit concurrency limit is not what runs. ProviderInfo says max_concurrent_job = 2, but the engine keys queues on the identifier prefix before -, so linkedin and linkedin-page share the linkedin queue and the code takes the maximum (8). Do not size rate-limit budgets from the declared 2.
  • companies() does not paginate. The ACL request sets q=roleAssignee and state=APPROVED but no count/start, so only LinkedIn’s default first page of organization ACLs is returned. A member who administers many pages may not see all of them in triggerTool; pass a page id directly to channelSetupTool if you already know it.
  • refresh_token() returns the member, not the page. It calls details_from_token(..., prefix_id = false), so the returned id is the bare member sub (no linkedin-page_ prefix and no organization id). The engine’s refresh path only stores the access/refresh token and expiry, so the channel’s organization internal_id is preserved; do not treat the refresh result’s id as the page id.
  • Same 60-day token and partner-only refresh as the personal provider. See linkedin.md.
  • Reconnect re-derives the page. reconnect() calls pages() and fetch_page_information() with the required organization id, returning the page’s name/logo/vanity name. It is only reached by the engine when root_internal_id differs from internal_id; normal OAuth channels leave root_internal_id null, so the shared member token is used directly.
  • Rate limits and versions are shared with the personal provider. The same 202601 / 202306 headers, the same fix_text escaping and the same company-only mention format apply.

References

Listmonk (listmonk)

Listmonk is a self-hosted newsletter and mailing-list manager. Rustiz turns a post into an HTML email campaign, creates it, and immediately starts it. The instance URL and API credentials are supplied per channel, encrypted in custom_instance_details. There is no developer app, no OAuth and no app review.

  • Rust source: crates/rustiz-providers/src/listmonk.rs
  • Upstream port: listmonk.provider.ts
  • Editor: HTML
  • Connection mode: customFields

Overview

PropertyValue
Identifierlistmonk
Display nameListMonk
Connection modecustomFields (instance URL, API user, API token)
Editorhtml
Channel kindsocial (email campaign)
Comments / threadsNo. supports_comments = false; only the first PostDetails is used
Pending / async publishingNo. supports_pending = false; returns PostStatus::Completed immediately after starting the campaign
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job100
Character limit (max_length)100000000 characters (effectively unlimited; Listmonk has no small body cap). The Postiz docs table lists 300,000, but the Rust port’s max_length() returns 100000000.
Inline imagesYes (inline_images = true)
APIListmonk REST API (/api/...) with HTTP Basic auth

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. The instance URL and credentials come from the channel, so rustiz providers --status reports listmonk as configured without server configuration.

VariableRequiredDefaultPurpose
JWT_SECRETNo, but should be set""Encrypts/decrypts the channel’s custom_instance_details (URL, username, password). It must be the same value at connect time and on every later call. If it changes, every Listmonk channel fails with Missing instance details. If it is unset consistently, an empty key is used (functional but insecure).
LISTMONK_BASE_URLNothe channel’s stored URLGlobal override for every Listmonk channel. When set, the stored instance URL is ignored and all calls go to this origin. Intended for tests/proxies; dangerous in a multi-instance deployment. The value is trimmed of a trailing slash.
ALLOW_PRIVATE_NETWORKNofalseGlobal SSRF switch. A Listmonk instance on localhost or a private IP is blocked unless this is true (or 1).

Why JWT_SECRET matters

connect_custom() in crates/rustiz-server/src/oauth.rs encrypts the entire custom-fields JSON with fixed_encryption(JWT_SECRET, ...). Listmonk::instance() decrypts it on every list/template/publish call. The provider reports a generic Missing instance details for a missing value, a wrong secret, or malformed JSON — it cannot tell them apart. Set a strong secret before connecting and never rotate it without reconnecting all Listmonk channels.

Step-by-Step Developer App Setup

There is no developer portal. Setup happens in the Listmonk admin UI.

  1. Sign in to Listmonk as an admin.

  2. Create a user role with the permissions Rustiz needs. In Listmonk v4+ go to Admin → Users → User roles → New:

    • settings:get — authenticate() calls GET /api/settings.
    • lists:get_all — the list tool calls GET /api/lists. Alternatively, attach a List role with at least View access to the target list.
    • templates:get — the templates tool calls GET /api/templates.
    • campaigns:manage (or campaigns:manage_all) — create the campaign.
    • campaigns:send — start the campaign. This is a separate permission and is required even if the role has campaigns:manage_all.
  3. Create a List role if the API user should be restricted to one or more lists. Assign at least View (and Manage, if the campaign should use it) for the target list. Without a list role, use lists:get_all / lists:manage_all.

  4. Create an API user: Admin → Users → Users → New, choose type API. Assign the user role and list role. Save and copy the generated access token — Listmonk shows it only once.

  5. Confirm the instance is reachable from the Rustiz server. HTTPS is strongly recommended; Basic auth sends the token on every request.

  6. Verify the credentials:

    curl -s -u 'rustiz_api:TOKEN' \
      https://listmonk.example.com/api/settings
    

    A 200 with a data.app.site_name field is success. A 401/403 means the token, username, or role permissions are wrong.

Products and permissions

  • No developer products or APIs to enable.
  • No OAuth scopes. Permissions are Listmonk user-role + list-role permissions.
  • The API user is not a normal login user: it has an automatically generated token, and the “password” in Rustiz is that token.
  • GET /api/lists and GET /api/templates are paginated by Listmonk. Rustiz does not pass page/per_page, so the tools return only the default first page.

Sandbox / testing mode

Listmonk has no hosted sandbox. Use a local or staging Listmonk instance and a test list containing only your own address.

  • A campaign is created and immediately set to running; it is not a draft. There is no send_at scheduling and no test-send step in Rustiz.
  • Never point a test at a production list with real subscribers.
  • If the instance is on a private address, set ALLOW_PRIVATE_NETWORK=true on Rustiz.

Production app review

There is no app review and no approval. Access is controlled entirely by the Listmonk API user’s role and list role. The same token works in production.

Connecting a Channel (CLI & MCP)

The credential payload has three keys:

KeyTypeValidation regexNotes
urltext`/^(https?://)(?:\S+(?::\S*)?@)?(?:(?:localhost)(?:\d{1,3}(?:.\d{1,3}){3})
usernametext/^.+$/Listmonk API user name, not the admin login.
passwordpassword/^.{3,}$/Listmonk API token. The field is labelled Password, but it is the token, not a password.

CLI

{
  "url": "https://listmonk.example.com",
  "username": "rustiz_api",
  "password": "listmonk-api-token"
}
rustiz channels connect listmonk --fields listmonk.json

printf '%s' '{"url":"https://listmonk.example.com","username":"rustiz_api","password":"listmonk-api-token"}' \
  | rustiz channels connect listmonk --fields -

Immediate result:

{ "connected": true, "pending": false, "name": "My Newsletter" }

The name is data.app.site_name from /api/settings, not the URL.

MCP

{
  "provider": "listmonk",
  "customFields": {
    "url": "https://listmonk.example.com",
    "username": "rustiz_api",
    "password": "listmonk-api-token"
  }
}

No follow-up step. Call integrationList for the channel id. Use --fields / customFields; --external-url / externalUrl is not the credential path for this provider.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
listnone[{"id": <number>, "name": "..."}]Calls GET /api/lists, reads data.results. Only the default first page. Requires lists:get_all or a list role.
templatesnone[{"id": 0, "name": "Default"}, {"id": <number>, "name": "..."}]Calls GET /api/templates. Prepends the synthetic Default entry with id 0; at publish time id 0 is filtered out and template_id is omitted, so Listmonk uses its default template.

MCP examples:

{ "integrationId": "int_...", "methodName": "list" }
{ "integrationId": "int_...", "methodName": "templates" }

Both functions have an empty input_schema / dataSchema. They ignore the stored access token and re-decrypt the instance details from the integration; the token argument exists only for the trait signature.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
subjectstringYesminLength: 1; must be a non-empty string. Used as the campaign subject and, slugified, as the campaign name.
previewstringYesMust be a string; it may be empty. Injected into the hidden preheader block of the HTML email.
liststringYesList id as a string. Parsed to an integer at publish time; a non-numeric string becomes null.
templatestringNoTemplate id as a string. Parsed to an integer; 0, missing, null, or a non-numeric value means “use the default template” and template_id is omitted.

Example object form:

{
  "subject": "Weekly update",
  "preview": "Three things worth reading",
  "list": "3",
  "template": "2"
}

MCP settings array:

{
  "settings": [
    { "key": "subject", "value": "Weekly update" },
    { "key": "preview", "value": "Three things worth reading" },
    { "key": "list", "value": "3" },
    { "key": "template", "value": "2" }
  ]
}

The engine adds "__type": "listmonk" automatically if missing.

validate_settings() rules

  1. subject must be a non-empty string.
  2. preview must be a string (empty allowed).
  3. list must be a string. There is no numeric check, so "abc" passes validation and becomes a null list at publish time.
  4. template, if present and not null, must be a string. A non-numeric string passes validation and is silently ignored at publish time.

Publish behavior

  1. POST /api/campaigns with:
    • name: slugify(subject) — Rustiz’s own slugifier maps & to and, | to or, < to less, > to greater, lowercases alphanumerics, and keeps Unicode letters/digits (unlike npm slugify strict mode).
    • type: "regular";
    • content_type: "html";
    • subject: the raw subject;
    • lists: [<list id>], or [null] if the id could not be parsed;
    • body: a fixed HTML wrapper with a hidden preheader, 18 repetitions of the zero-width padding sequence, and <div class="content">message</div>;
    • template_id: included only when a non-zero template id was parsed.
  2. Reads data.uuid as the post id and data.id as the campaign id.
  3. PUT /api/campaigns/{id}/status with {"status":"running"}.
  4. Returns post_id = uuid and release_url = {instance}/api/campaigns/{id}/preview.

The preview URL requires Listmonk authentication; it is not a public newsletter URL.

Media requirements

  • Listmonk has no media upload endpoint in this provider. inline_images = true means Rustiz’s HTML sanitizer keeps <img> tags in the content and allows the img tag, so images must be public URLs in the HTML body.
  • The post media / attachments array is ignored.
  • check_validity is the trait default (Ok(None)); there are no provider-specific media checks.
  • Listmonk templates may rewrite or drop images depending on the selected template; the default template renders the campaign body.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • The campaign starts immediately. post() does not send send_at and does not leave the campaign as a draft. It creates the campaign and then sets status to running. Rustiz’s scheduled date only decides when the publish job calls Listmonk; once called, sending begins.
  • Non-idempotent create + start. If the create succeeds and the status call fails, the campaign remains a draft and the post errors. A retry creates a second campaign. Check Listmonk for a draft campaign before retrying a failed ListMonk post.
  • LISTMONK_BASE_URL is a global override. In a deployment with multiple Listmonk instances, setting it makes every channel talk to the same host. Prefer leaving it unset and storing the URL per channel.
  • The Basic-auth value is stored as both token and refresh token. authenticate() sets access_token = refresh_token = base64(username:password), with a 100-year expiry. post() ignores the passed token and decrypts the instance details again, so the stored token is mostly informational.
  • Credentials are encrypted, not hashed. The URL, username and token are recoverable with JWT_SECRET. Protect the secret like a database key.
  • list and template ids are strings in settings but integers on the wire. A non-numeric list passes validation and produces lists: [null]. Listmonk will normally reject that campaign; validate the id before scheduling.
  • Tools show only the first page. Neither list() nor templates() passes pagination parameters. A Listmonk instance with many lists or templates will not show all of them; the editor may need the id entered manually.
  • Preview URL needs auth. The returned releaseURL is an API preview endpoint, not a public archive link. Opening it in a browser without a Listmonk session returns 401/403.
  • Campaign name can be empty. slugify("") yields an empty string; the subject validator prevents an empty subject, but a subject made only of punctuation can still produce an empty name. Listmonk may reject it.
  • inline_images only affects sanitization. It does not upload images or rewrite relative paths. Use absolute public URLs in the HTML.
  • No analytics. Listmonk has campaign analytics, but this provider does not implement analytics() or post_analytics(); the flags are false.
  • No refresh. Credentials never expire. A 401/403 means the API user, token or permissions changed; reconnect.
  • Rate limiting is deployment-specific. Listmonk itself may return 429 behind a reverse proxy or rate-limit middleware; Rustiz’s generic HTTP layer retries 429/500 up to three times with the configured delay.

References

M. Instance (mastodon-custom)

mastodon-custom is the any-instance Mastodon provider. Instead of using a server-registered app, the server calls the target instance’s POST /api/v1/apps during the connect handshake, stores the returned client id/secret encrypted, and then runs the normal Mastodon OAuth flow on that instance. Users only supply the instance URL.

  • Rust source: crates/rustiz-providers/src/mastodon.rs (Mastodon::custom)
  • Upstream port: mastodon.custom.provider.ts
  • Editor: Normal
  • Connection mode: externalUrl
  • Shared code: the posting, media and pending logic is identical to mastodon; this page covers the differences.

Overview

PropertyValue
Identifiermastodon-custom
Display nameM. Instance
Connection modeexternalUrl (the server registers an OAuth app on the instance)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true
Pending / async publishingYes. supports_pending = true
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse (the instance URL is collected before the OAuth login, not as a second step)
convert_to_jpegfalse
max_concurrent_job5
Character limit (max_length)500 characters, hard-coded
OAuth scopeswrite:statuses, profile, write:media
Redirect URI{FRONTEND_URL}/integrations/social/mastodon (same as mastodon)

The display name “M. Instance” is deliberate: it is the provider for “some Mastodon instance”, not a hosted mastodon.social app.

Server Environment Variables

required_env() returns nothing:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    if self.custom {
        &[]
    } else {
        &["MASTODON_CLIENT_ID", "MASTODON_CLIENT_SECRET"]
    }
}
}
VariableRequiredDefaultPurpose
MASTODON_URLNohttps://mastodon.socialFallback instance only. Used by instance_url() if the channel’s encrypted customInstanceDetails cannot be decrypted or has no instanceUrl, and as default_instance() for edge cases.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI registered on every instance
JWT_SECRETYesemptyEncrypts the per-channel {instanceUrl, client_id, client_secret} blob; changing it breaks existing channels

There is no MASTODON_CUSTOM_* env var and no MASTODON_BASE_URL override. The instance is per channel, supplied as externalUrl at connect time.

rustiz providers --status reports mastodon-custom as configured even with no environment at all, because it needs none. It also reports the connection mode as externalUrl; agents use that to know they must pass externalUrl.

Step-by-Step Developer App Setup

There is no developer portal step for the operator. Each user’s instance registers the app on demand.

What the server does during connect

oauth::begin() sees externalUrl, calls Mastodon::external_url(url), which posts a multipart form to:

POST {externalUrl}/api/v1/apps

with exactly:

FieldValue
client_nameRustiz (hard-coded)
redirect_uris{FRONTEND_URL}/integrations/social/mastodon
scopeswrite:statuses profile write:media (space-separated)
website{FRONTEND_URL}

The instance returns client_id and client_secret. Rustiz stores them in the OAuth state row (encrypted with JWT_SECRET) and builds the authorize URL on that instance:

{externalUrl}/oauth/authorize?client_id=…&response_type=code
  &redirect_uri={urlencoded {FRONTEND_URL}/integrations/social/mastodon}
  &scope=write:statuses+profile+write:media
  &state=…

After the human authorizes, oauth::callback() decrypts the client info and calls Mastodon::authenticate() with ClientInformation {client_id, client_secret, instance_url}. The token exchange is a multipart POST to {externalUrl}/oauth/token with client_id, client_secret, code, grant_type=authorization_code, redirect_uri and scope. The resulting integration stores custom_instance_details as an encrypted JSON object:

{
  "instanceUrl": "https://fosstodon.org",
  "client_id": "…",
  "client_secret": "…"
}

instance_url() later decrypts this and reads instanceUrl (trimming a trailing slash). It never reads the client credentials at publish time.

Redirect URI (exact)

The redirect URI is hard-coded in Mastodon::redirect_uri():

{FRONTEND_URL}/integrations/social/mastodon

Key consequences:

  • It is mastodon, not mastodon-custom. The OAuth app registered on the user’s instance must use /integrations/social/mastodon.
  • There is no trailing slash and no redirectmeto.com wrapper.
  • The callback route is GET /integrations/social/{provider} and ignores the path provider when resolving state; it looks up the state row, which stores provider = "mastodon-custom". This is why the shared path works.
  • If FRONTEND_URL is http://localhost:5000, the registered redirect is http://localhost:5000/integrations/social/mastodon. Some instances refuse plain-http redirects except for loopback; most accept localhost.

Scopes requested and checked

The app is registered with and the authorize URL requests:

write:statuses profile write:media
ScopeWhy
write:statusesCreate the status/replies
profileverify_credentials during connect
write:mediaUpload attachments and poll processing

The profile scope was added in Mastodon 4.3. It only grants GET /api/v1/accounts/verify_credentials (and GET /oauth/userinfo in 4.4+), which is exactly what the connect flow needs. An instance older than 4.3 does not know the scope: app registration or the authorize request can fail with invalid_scope, or the token may be issued without access to verify_credentials. The provider has no fallback to read:accounts; on an older instance, change Mastodon::scopes() to read:accounts and re-register the app.

check_scopes() is never called. The port does not compare the granted scopes with info.scopes. If the instance strips a scope (some do), the connection succeeds and the missing operation fails later.

Sandbox / testing vs production

Mastodon has no sandbox, no test app and no app review. For mastodon-custom:

  • Test with a test account on any instance, or a local Mastodon instance.
  • Each connect attempt creates a new application on the instance. Repeated failed attempts leave orphaned apps in the user’s Preferences → Development list. Delete them there; Rustiz has no cleanup call.
  • Some instances disable app creation, require admin approval, or run in authorized-fetch mode. The connect call fails at POST /api/v1/apps; there is no review process to appeal to.
  • Production uses the same flow. No credentials are submitted to Mastodon or to the instance operator in advance.

Do not set MASTODON_CLIENT_ID / MASTODON_CLIENT_SECRET for this provider

They are not read when custom == true. Setting them is harmless but misleading; rustiz providers --status ignores them for this provider.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect mastodon-custom \
  --external-url https://fosstodon.org \
  --open
  • --external-url is required. Without it, the server returns: mastodon-custom needs \externalUrl`, for example {“provider”:“mastodon-custom”,“externalUrl”:“https://mastodon.social”}.`
  • The URL is trimmed of trailing slashes before POST /api/v1/apps and before storing it. https://fosstodon.org/ and https://fosstodon.org are equivalent.
  • With a local FRONTEND_URL, the CLI serves the callback and waits for the channel. With a public HTTPS FRONTEND_URL, it prints the login URL and exits; poll rustiz channels connect-status <connectionId>.

MCP connectChannelTool

{
  "provider": "mastodon-custom",
  "externalUrl": "https://fosstodon.org"
}

The tool returns an OAuth start:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://fosstodon.org/oauth/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with:

{ "connectionId": "…" }

integrationSchema describes it as:

{
  "type": "externalUrl",
  "headless": false,
  "requiresHuman": true,
  "requiresExternalUrl": true
}

There is no triggerTool / channelSetupTool follow-up. The instance URL is collected up front; the only human step is the OAuth login.

What not to send

  • Do not send customFields. connect.rs gives customFields precedence over every other flow. For mastodon-custom, connect_custom() would call authenticate() without a ClientInformation; the custom branch requires one and returns Missing instance details. The error is confusing because the flow never reaches external_url().
  • externalUrl must be the instance origin (https://fosstodon.org), not an API path (https://fosstodon.org/api/v1) and not an OAuth URL.
  • The instance must speak the Mastodon API. GoToSocial, Pleroma and Akkoma mostly work, but POST /api/v1/apps response shape and media processing semantics vary. This provider is documented for Mastodon.

Post Settings & Media Validation

Identical to the standard provider:

settings_schema()

The trait default, with no fields:

{ "type": "object", "properties": {}, "additionalProperties": true }

__type is added by the engine; visibility, CW and sensitive media are not configurable.

validate_settings()

Not overridden; every object is accepted.

check_validity()

Not overridden; no pre-flight media checks. The instance enforces its own limits.

Media requirements

  • Up to 4 images, 16 MB each; or 1 video/audio/GIF, 99 MB.
  • Alt text goes in the description multipart field, only when non-empty.
  • Media is uploaded to {instance}/api/v1/media (v1), then polled through GET /api/v1/media/:id until 200.
  • Instance-specific limits can be lower (some small instances cap uploads at a few MB or disable video). Rustiz does not read the instance configuration.

Threads

supports_comments = true: each extra entry becomes a reply with in_reply_to_id pointing at the previous status. Every reply is visibility=public. Each reply uploads its own media.

Plugs

No @Plug / @PostPlug methods; both default to Unsupported.

Implementation Notes & Gotchas

  • The app is created per connect attempt, not per instance. Two users on the same instance get two separate OAuth apps, both named Rustiz and both with the same redirect URI. This is intentional (no shared secret between tenants) but clutters the instance’s application list.
  • The app name and website are hard-coded. client_name="Rustiz" and website=FRONTEND_URL. To white-label, change the Rust code; there is no env var.
  • instance_url() silently falls back. If JWT_SECRET changes, the encrypted customInstanceDetails cannot be decrypted; instance_url() returns MASTODON_URL (default https://mastodon.social) and the channel’s token from the original instance is sent there. Expect a 401, not a clear “wrong instance” error. Back up JWT_SECRET.
  • Trailing slash handling. external_url() trims the URL; instance_url() trims it again; auth_url() trims it a third time. https://host/ works, but https://host/api/v1 does not.
  • No environment readiness check. provider_status() sees no required env vars, so the UI/agent can start a connection even when FRONTEND_URL is not publicly reachable. The login will fail at the callback.
  • A bad app-registration response is caught before the human sees it. oauth::begin() builds the authorize URL and connect.rs oauth_url_configured() rejects an empty client_id (or undefined) in the query string, deletes the pending state and returns “its OAuth client id is empty”. If an instance accepts POST /api/v1/apps but returns an unexpected shape, this is the error you see.
  • OAuth state TTL is one hour. The state row stores the encrypted client id/secret. If the human takes longer than an hour, the callback returns This connection link expired, please start again, and a new app is created on the instance on the next attempt.
  • Same posting code as mastodon. The pending handshake, media polling, Idempotency-Key on finalize, public-only visibility, missing comment idempotency, and hard-coded 500-character limit all apply. See the mastodon page for the full list.
  • Rate limits are per instance. max_concurrent_job = 5 is a conservative default; a large instance may allow more, a small one less. Mastodon’s documented default is 300 requests per 5 minutes per account and 30 media uploads per 30 minutes.
  • externalUrl is only used at connect time. Publishing uses the stored instanceUrl, so moving the instance to a new hostname requires reconnecting the channel (or editing the encrypted blob, which is not supported).
  • Error mapping is shared. The same three body substrings (Your login is currently disabled, not finished processing, could not be found) are converted to refresh/bad-body errors.

References

Mastodon (mastodon)

Mastodon is a federated social network: every account lives on one instance and the OAuth app is registered on that instance. Rustiz’s mastodon provider uses one pre-registered app on MASTODON_URL (default https://mastodon.social); to connect an account on any other instance without server-side credentials, use mastodon-custom.

  • Rust source: crates/rustiz-providers/src/mastodon.rs
  • Upstream port: mastodon.provider.ts
  • Editor: Normal
  • Connection mode: oauth
  • Related provider: mastodon-custom (externalUrl, shares the same Rust implementation and the same redirect path)

Overview

PropertyValue
Identifiermastodon
Display nameMastodon
Connection modeoauth (a human opens a login URL)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are published as replies with in_reply_to_id
Pending / async publishingYes. supports_pending = true; media is uploaded first, then polled, then the status is created
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job10 (Mastodon limits are per user, not per app)
Character limit (max_length)500 characters, hard-coded
OAuth scopeswrite:statuses, profile, write:media
Redirect URI{FRONTEND_URL}/integrations/social/mastodon

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["MASTODON_CLIENT_ID", "MASTODON_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
MASTODON_CLIENT_IDYes—Client id returned by POST /api/v1/apps on MASTODON_URL
MASTODON_CLIENT_SECRETYes—Client secret returned by the same call
MASTODON_URLNohttps://mastodon.socialInstance that hosts the app and the accounts. Trailing slashes are stripped. Read with Config::get_or, not base_url.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI
JWT_SECRETYes in practiceemptyEncrypts the per-connection client information used by mastodon-custom, and is used by the rest of the server for stored secrets. Not in required_env().

There is no MASTODON_BASE_URL override in this provider. The instance is MASTODON_URL; tests point that variable at a mock server. MASTODON_URL is not part of required_env(), so rustiz providers --status reports mastodon as configured even when the URL is unreachable or the app was registered on a different instance.

rustiz providers --status only checks that MASTODON_CLIENT_ID and MASTODON_CLIENT_SECRET are non-empty. It cannot tell whether they belong to the instance in MASTODON_URL.

Step-by-Step Developer App Setup

1. Pick the instance

The standard provider is single-instance by design:

  • generate_auth_url() sends the human to {MASTODON_URL}/oauth/authorize.
  • authenticate() exchanges the code at {MASTODON_URL}/oauth/token.
  • post() and comment() call {MASTODON_URL}/api/v1/....

An app registered on mastodon.social cannot authorize an account on fosstodon.org. If you need multiple instances, either run one Rustiz deployment per instance and change MASTODON_URL, or use mastodon-custom, which registers an app on each user’s instance.

2. Register the app on the instance

Postiz’s docs register the app through the API; the instance’s web UI (Preferences → Development → New application) produces an equivalent app.

Using the API (replace the redirect URI with your deployment’s exact value):

curl -X POST -sS "https://mastodon.social/api/v1/apps" \
  -F "client_name=Rustiz" \
  -F "redirect_uris=https://social.example.com/integrations/social/mastodon" \
  -F "scopes=write:statuses profile write:media" \
  -F "website=https://social.example.com"

The response contains client_id and client_secret:

{
  "id": "1234567890",
  "name": "Rustiz",
  "redirect_uris": ["https://social.example.com/integrations/social/mastodon"],
  "scopes": ["write:statuses", "profile", "write:media"],
  "client_id": "…",
  "client_secret": "…"
}

Alternatively, sign in to the instance and open Preferences → Development → New application (https://<instance>/settings/applications/new). Set the redirect URI and check exactly the three scopes above. The web form and the API produce the same kind of app.

The client_name is what users see on the consent screen. Rustiz’s mastodon-custom hard-codes it to Rustiz; for the standard provider you can name it anything. There are no products or API subscriptions to enable; Mastodon’s scopes are the only permission gate.

3. Set the environment and restart

MASTODON_CLIENT_ID="…"
MASTODON_CLIENT_SECRET="…"
MASTODON_URL="https://mastodon.social"

Restart every process that reads the configuration (rustiz serve, rustiz worker, rustiz mcp). Check:

rustiz providers --status

4. Redirect URI (exact)

Mastodon::redirect_uri() is:

#![allow(unused)]
fn main() {
format!(
    "{}/integrations/social/mastodon",
    self.ctx.config.frontend_url()
)
}

Config::frontend_url() trims trailing slashes from FRONTEND_URL, so the registered value must be without a trailing slash:

https://social.example.com/integrations/social/mastodon

Local examples:

http://localhost:5000/integrations/social/mastodon   # Docker / rustiz serve default
http://localhost:4200/integrations/social/mastodon   # upstream Postiz dev UI

There is no redirectmeto.com wrapper for Mastodon. mastodon-custom reuses this exact path (see its page), because the Rust code hard-codes mastodon, not mastodon-custom.

Mastodon requires the redirect_uri sent to /oauth/authorize and /oauth/token to be one of the URIs registered on the app. If you register a trailing slash, or http instead of https, the token exchange fails with invalid_grant / The provided redirect_uri is not valid.

5. Scopes requested and checked

Mastodon::scopes() returns, in this order:

#![allow(unused)]
fn main() {
vec!["write:statuses", "profile", "write:media"]
}
ScopeWhy Rustiz needs it
write:statusesPOST /api/v1/statuses (posts and replies)
profileGET /api/v1/accounts/verify_credentials during connect
write:mediaPOST /api/v1/media and GET /api/v1/media/:id

The profile scope was added in Mastodon 4.3. It only grants GET /api/v1/accounts/verify_credentials (and GET /oauth/userinfo in 4.4+), which is exactly what the connect flow needs. An instance older than 4.3 does not know the scope: app registration or the authorize request can fail with invalid_scope, or the token may be issued without access to verify_credentials. The provider has no fallback to read:accounts; on an older instance, change Mastodon::scopes() to read:accounts and re-register the app.

The authorize URL joins them with +:

{instance}/oauth/authorize?client_id=…&response_type=code
  &redirect_uri={urlencoded redirect}
  &scope=write:statuses+profile+write:media
  &state=…

The token request sends the same three scopes space-separated in a multipart form. The app must be registered with all three. Mastodon refuses a token request whose scope is not a subset of the app’s registered scopes.

check_scopes() is never called by this provider. The Rust port requests the scopes but does not verify the token response’s granted scopes. If an administrator edits the app and removes write:media, the connection still succeeds and only media uploads fail later.

6. Sandbox / testing vs production

Mastodon has no sandbox, no test-mode app and no app review.

  • Test against a small instance or a dedicated test account. The first post is public on the account’s profile unless the account is locked or the instance is configured otherwise (Rustiz always asks for visibility=public).
  • mastodon.social allows self-service app creation; some instances restrict registrations, require admin approval for new accounts, or enable authorized-fetch mode. Those are instance policies, not Mastodon app review.
  • There is no “development mode”: the app works for every account on the instance as soon as it is registered.
  • Production requires the same three env vars and a reachable MASTODON_URL. Nothing is submitted to Mastodon gGmbH for review.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect mastodon --open
  • Prints the login URL to stderr; --open launches the browser.
  • With a local FRONTEND_URL (http://localhost:… / http://127.0.0.1:…) and no separate server, the CLI serves the callback itself on the frontend port and waits up to --timeout seconds (default 300) for the channel to appear.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll rustiz channels connect-status <id> or rustiz channels list.

Do not pass --external-url to mastodon: Mastodon::external_url() is only implemented when self.custom == true, so the standard provider returns Unsupported("externalUrl"). Use mastodon-custom for that.

MCP connectChannelTool

{
  "provider": "mastodon",
  "externalUrl": null,
  "customFields": null
}

The tool returns either a completed connection or:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://mastodon.social/oauth/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Give the url to a human, then call again with:

{ "connectionId": "…" }

connectChannelTool with an MCP client that supports URL elicitation sends the login URL through elicitation/create instead of returning it as text.

integrationSchema reports connection: {type:"oauth", headless:false, requiresHuman:true, requiresExternalUrl:false}. There is no follow-up triggerTool / channelSetupTool: the account is stored directly and is_between_steps is false.

What not to send

  • externalUrl is not supported by mastodon: begin() calls Mastodon::external_url(), which returns Unsupported("externalUrl"). Use mastodon-custom.
  • customFields takes precedence for any provider in connect.rs. Sending an object to mastodon routes to connect_custom(), which encodes it as the code of authenticate(); that expects an OAuth authorization code, so the connection fails. Never send customFields to mastodon.

Post Settings & Media Validation

settings_schema()

Mastodon does not override settings_schema(). The trait default is:

{ "type": "object", "properties": {}, "additionalProperties": true }

There are no provider-specific settings. The engine adds "__type": "mastodon" to the stored settings. Visibility, content warnings, sensitive media, polls and language are not exposed and are not read by the provider.

validate_settings()

Not overridden: every settings object is accepted.

check_validity()

Not overridden: no pre-flight media rules. Mastodon’s own API enforces them.

Media requirements

Rustiz uploads every media item with POST /api/v1/media (the v1, synchronous endpoint) before creating the status:

MediaMastodon limitNotes
ImagesUp to 4 per status, 16 MB each (PNG, JPG, HEIF, WEBP, AVIF)Downscaled by the instance to about 8.3 MP
Animated GIF / GIFV1 per status, 16 MBConverted to a soundless MP4; < 1 MP
Video1 per status, 99 MB (MP4, M4V, MOV, WebM)Transcoded to H.264 MP4
Audio1 per status, 99 MBTranscoded to MP3
Alt textPer itemSent as the multipart description only when non-empty

The provider sends all files in one multipart request per media item. The file part carries the filename from rustiz_core::media::file_name(path); the code does not set an explicit MIME type on the part.

A status cannot mix a poll with media. Rustiz does not send polls at all.

Threads

supports_comments = true, so a multi-entry value array is published as:

  1. The first entry as a status.
  2. Each later entry as a reply, with in_reply_to_id set to the previous status id (or the root post id for the first reply).
  3. Every reply uses visibility=public.

Each reply uploads its own media before posting. There is no thread-root metadata beyond in_reply_to_id, which is exactly what Mastodon needs.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Pending handshake. post_pending() uploads all media, stores {url, message, mediaIds, idempotencyKey} in pending_data, and returns PostStatus::Pending. The engine then polls check_post_status() every 20 seconds (up to 90 checks, about 30 minutes). When every media id answers 200, it calls finalize_post() to create the status.
  • Media polling semantics. GET /api/v1/media/:id returns 206 Partial Content while processing and 200 OK when ready. 401 becomes a refresh error. 404/410 are treated as “already attached by an earlier finalize” and skipped, not as a hard failure. 422 means the upload expired and is reported as The uploaded media expired before the post was published, please post again.
  • Idempotent finalize. The finalize request sends Idempotency-Key: <postId>-<5 random chars>, generated once in post_pending(). Mastodon stores idempotency keys for about one hour; a retry inside that window replays the original response instead of creating a second status. A retry after the window can duplicate the post.
  • Comments are not idempotent. comment() does not send an Idempotency-Key. A retried comment can appear twice.
  • Two posting paths. post() is the blocking compatibility path (post_pending → poll → finalize, 8-minute timeout, 20-second sleeps). The engine uses post_pending() because supports_pending = true.
  • Always public. Both finalize_post() and comment() hard-code visibility=public. There is no setting for unlisted, private, direct, content warnings, sensitive media, language or polls.
  • Hard-coded 500 characters. max_length() returns 500 regardless of the instance’s configuration.statuses.max_characters (some instances allow 5,000). The engine counts characters after HTML stripping. Mastodon counts every URL as 23 characters; Rustiz counts the whole URL, so a post with a long URL can be rejected locally even though Mastodon would accept it.
  • Media limit not pre-checked. check_validity() is the default no-op. Uploading five images succeeds for each upload, then POST /statuses fails. Check the account’s instance limits before scheduling large carousels.
  • The v1 media endpoint is deprecated. The code uses POST /api/v1/media and accepts 200, 201 or 202. Mastodon 3.1.3+ documents POST /api/v2/media as the replacement; v1 still works and blocks until the attachment is processed. The follow-up GET /api/v1/media/:id poll remains correct for both.
  • Media MIME type. The multipart file part is built with Part::stream(...).file_name(...) and no Content-Type. Mastodon’s docs ask for a part with a MIME type. Reqwest may send application/octet-stream; if an instance rejects an otherwise valid image with File content type is invalid, set the part’s MIME type by content sniffing.
  • No token refresh. Mastodon access tokens normally do not expire. authenticate() stores refresh_token: Some("null") (the literal string) and expires_in of 100 years plus 25 days. refresh_token() returns an empty access token and expires_in: 0. A 401 therefore cannot be repaired automatically; reconnect the channel. A 401 during media polling is explicitly converted to a refresh error.
  • MASTODON_URL is read at publish time. For the standard provider, instance_url() always returns the current MASTODON_URL, not the instance used at connect time. Changing MASTODON_URL after connecting makes every existing channel post its stored token to the new host.
  • MASTODON_URL default. https://mastodon.social. If you leave it unset and register the app on another instance, the login will fail with an invalid_client page on mastodon.social.
  • Error mapping. handle_errors() maps three body substrings: Your login is currently disabled → refresh/reconnect, not finished processing → “The media was still processing…”, could not be found → “The uploaded media expired…”. Other Mastodon errors pass through the generic HTTP layer, which retries 429/5xx up to three times with the configured delay.
  • Rate limits. Mastodon defaults: 300 requests per 5 minutes per account and per IP, and 30 media uploads per 30 minutes. max_concurrent_job = 10 is optimistic for a media-heavy queue because each post spends at least one request per attachment plus the status call.
  • Release URL shape. The provider builds {instance}/statuses/{statusId}. Mastodon’s API returns a canonical url (usually https://{instance}/@{user}/{id}); the constructed URL is a redirect-style path, not necessarily the exact permalink. Analytics or link-checking code should follow the redirect.
  • SSRF and local instances. OAuth and API calls go through the shared HTTP client, which blocks private IPs by default. A Mastodon instance on a private network needs DISABLE_SSRF_PROTECTION=true (global, dangerous) or a deployment that can resolve the host publicly.
  • check_scopes() absent. As above: granted scopes are never compared with the required list. An app edited after registration is only discovered when the missing endpoint is called.

References

Medium (medium)

Medium is an article provider with a legacy API. Rustiz sends a self-issued Medium integration token as a Bearer token, reads the user’s profile and publications, and creates a story either on the user’s profile (public) or in a publication (always as a draft).

  • Rust source: crates/rustiz-providers/src/medium.rs
  • Upstream port: medium.provider.ts
  • Editor: Markdown
  • Connection mode: customFields

Important: Medium stopped issuing new integration tokens and does not allow new API integrations. Existing tokens continue to work. If the account does not already have a token, the medium provider cannot be used. See https://help.medium.com/hc/en-us/articles/213480228-API-Importing.

Overview

PropertyValue
Identifiermedium
Display nameMedium
Connection modecustomFields (legacy integration token)
Editormarkdown
Channel kindsocial (article content)
Comments / threadsNo. supports_comments = false; only the first PostDetails is published
Pending / async publishingNo. supports_pending = false; returns PostStatus::Completed
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)100000 characters for the story body
APIMedium API v1, https://api.medium.com/v1

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. rustiz providers --status reports medium as configured with no server environment.

VariableRequiredDefaultPurpose
MEDIUM_BASE_URLNohttps://api.medium.comOverrides the API origin. The value is trimmed of a trailing slash. The paths added by the provider include /v1/..., so the override should be the origin only.

Step-by-Step Developer App Setup

There is no developer app to create in Rustiz. The provider does not implement Medium’s browser OAuth flow at all; it only accepts a self-issued integration token.

  1. Sign in to Medium.

  2. Open Settings → Security and apps → Integration tokens (https://medium.com/me/settings). Medium’s own API documentation calls the section Integration Tokens.

  3. If the page still offers Get integration token, create one with a descriptive name such as Rustiz. Medium states that it is no longer issuing new tokens, so many accounts will not see this option.

  4. Copy the token and verify it:

    curl -s https://api.medium.com/v1/me \
      -H "Authorization: Bearer $MEDIUM_TOKEN"
    

    The response is {"data":{"id","username","name","url","imageUrl"}}, which is exactly what Rustiz stores.

Products and permissions

  • No products or APIs to enable.
  • No OAuth scopes in this flow. A legacy OAuth integration would have used basicProfile, listPublications, publishPost and optionally uploadImage, but Rustiz never performs that flow.
  • The token is account-wide. It can publish on the profile and create drafts in publications where the user is a writer or editor.

Sandbox / testing mode

Medium has no sandbox. The API documentation explicitly says there is no sandbox and recommends a separate testing account; requests act on production data at medium.com.

  • The safest test is a publication post: Rustiz uses publishStatus: "draft" whenever settings.publication is set. A draft in a publication stays pending until an editor publishes it.
  • A post without publication uses publishStatus: "public" and is live immediately.
  • GET /v1/me and GET /v1/users/{id}/publications are safe read-only checks.

Production app review

There is no app review because Medium is not accepting new integrations. Existing tokens are not reviewed or re-approved. The practical “review” is Medium’s decision to keep the legacy API alive; do not build a new onboarding flow that assumes new users can obtain a token.

Connecting a Channel (CLI & MCP)

The credential payload has exactly one key:

KeyTypeValidation regexNotes
apiKeypassword/^.{3,}$/Legacy Medium integration token

CLI

{ "apiKey": "2f1a9c...medium-token" }
rustiz channels connect medium --fields medium-key.json

printf '%s' '{"apiKey":"2f1a9c...medium-token"}' \
  | rustiz channels connect medium --fields -

Immediate result:

{ "connected": true, "pending": false, "name": "Your Medium name" }

MCP

{
  "provider": "medium",
  "customFields": {
    "apiKey": "2f1a9c...medium-token"
  }
}

No follow-up step. Call integrationList for the channel id. Use --fields / customFields; --external-url / externalUrl is not the credential path for this provider.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
publicationsnoneThe data array from GET /v1/users/{id}/publicationsUses integration.internal_id (the Medium user id) as the path id, not the channel database id. Each item has id, name, description, url, imageUrl.

MCP:

{ "integrationId": "int_...", "methodName": "publications" }

The function has an empty input_schema / dataSchema.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
titlestringYesminLength: 2; checked. Medium itself ignores titles longer than 100 characters and synthesizes one from the content.
subtitlestringYesminLength: 2; checked. It is never sent to Medium — the provider comment says Medium’s API does not accept it on publish. It exists for parity with upstream.
canonicalstringNoSent as canonicalUrl. If it contains (post:, URL validation is skipped.
publicationstringNoMedium publication id. When set, the story is created under the publication with publishStatus: "draft". When empty, it is published on the user’s profile with publishStatus: "public".
tagsarrayNomaxItems: 4; each item needs a string value (the tag name sent to Medium) and a string label (editor display only). Medium only uses the first three tags and ignores tags longer than 25 characters.

The schema’s required array is ["title", "subtitle"]. This surprises people who expect Medium’s subtitle to be optional: Rustiz rejects a story without a subtitle even though the subtitle is discarded.

Example object form:

{
  "title": "A week with SQLite",
  "subtitle": "Notes from a small database",
  "canonical": "https://blog.example/sqlite-week",
  "publication": "",
  "tags": [
    { "value": "rust", "label": "Rust" },
    { "value": "sqlite", "label": "SQLite" }
  ]
}

MCP settings array:

{
  "settings": [
    { "key": "title", "value": "A week with SQLite" },
    { "key": "subtitle", "value": "Notes from a small database" },
    { "key": "tags", "value": [
      { "value": "rust", "label": "Rust" },
      { "value": "sqlite", "label": "SQLite" }
    ] }
  ]
}

The engine adds "__type": "medium" automatically if missing.

validate_settings() rules

  1. title must exist, be a string and have at least 2 characters.
  2. subtitle must exist, be a string and have at least 2 characters.
  3. canonical, if present and a string, must be a valid URL unless it contains (post:.
  4. publication, if present and not null, must be a string. An empty string is accepted and means “publish on the profile”.
  5. tags, if present and not null, must be an array of at most 4 items. Each item must be an object with a string value and a string label. The validator does not check the 25-character Medium tag limit or the first-three rule; Medium applies those itself.

Media requirements

  • There is no main_image field in the schema. Medium is not sent a cover image and Rustiz does not call Medium’s image upload endpoint.
  • The post media / attachments array is ignored.
  • Images can only appear if the Markdown body references public image URLs; Medium side-loads images referenced by <img> tags in the content.
  • The Rustiz media validator is the trait default (Ok(None)), so there are no provider-specific media checks.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Medium is closed to new integrations. This is the dominant operational fact. The provider will connect with any 3+ character string that Medium accepts as a token, but Medium will reject tokens it did not issue. New users cannot onboard.
  • Publication posts are always drafts. post() sets publishStatus: "draft" whenever publication is non-empty, even for a user who is an editor and could publish directly. This is inherited from upstream Postiz and is a safe default for writers, but the story will not appear publicly until an editor publishes it.
  • A profile post is always public. With no publication, the status is "public"; there is no unlisted setting in Rustiz.
  • Subtitle is mandatory but discarded. The schema and validator require it, and post() never includes it in the request body. If you need a subtitle visually, put it in the Markdown content.
  • Tags are capped at 4 in Rustiz, 3 in Medium. The fourth tag is silently ignored by Medium. label is not sent; value is.
  • Title >100 characters is ignored by Medium. Rustiz has no title max, so a long title passes validation and Medium synthesizes a title from the first content instead.
  • (post: canonical placeholder is sent if present. Validation skips URL checks for it, but post() sends any non-empty canonical string as canonicalUrl. Omit the field to leave the canonical URL out.
  • No refresh. Tokens are stored with a 100-year expires_in and an empty refresh token; refresh_token() returns an empty access token. A revoked token requires reconnecting with another legacy token.
  • publications needs the Medium user id. It uses integration.internal_id, which authenticate sets from data.id; it does not use the channel database id. If a channel was migrated from another system with a different internal id, the call will list the wrong user’s publications or fail.
  • No rate-limit mapping. handle_errors() returns None; generic HTTP handling retries 429/500 up to three times and treats 401 as a refresh request. Medium does not publish a sandbox or a detailed rate-limit table for this legacy API.

References

MeWe (mewe)

MeWe is a social network with personal timelines and groups. Rustiz connects a MeWe account through MeWe’s standalone-app OAuth flow, then publishes text + photos either to the user’s timeline or to a group. There is no native comment/thread support in this provider.

  • Rust source: crates/rustiz-providers/src/mewe.rs
  • Upstream port: mewe.provider.ts
  • Editor: Normal (plain text)
  • Connection mode: oauth

Overview

PropertyValue
Identifiermewe
Display nameMeWe
Connection modeoauth
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments is the default false; comment() is not implemented
Pending / async publishingNo. supports_pending = false; post() returns PostStatus::Success
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job1 (default)
Character limit (max_length)63_206
API hosthttps://mewe.com (MEWE_BASE_URL, or MEWE_HOST)
API styleREST under /api/dev/..., app headers + bearer token

Server Environment Variables

required_env() returns two variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["MEWE_APP_ID", "MEWE_API_KEY"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
MEWE_APP_IDYes""The app id (X-App-Id) from the MeWe developer portal.
MEWE_API_KEYYes""The app’s secret API key (X-Api-Key).
MEWE_BASE_URLNo—Preferred host override; base_url("MEWE", ...) reads MEWE_BASE_URL first and strips trailing slashes.
MEWE_HOSTNohttps://mewe.comFallback host used only when MEWE_BASE_URL is unset. This is a legacy/default name, not the *_BASE_URL override.
FRONTEND_URLNohttp://localhost:5000Public origin; the OAuth redirect URI is derived from it. Trailing slashes are stripped.
JWT_SECRETNo""Not used by this OAuth provider (no encrypted client info or custom fields). Set it for the rest of the deployment.

Host precedence in code:

#![allow(unused)]
fn main() {
self.ctx.config.base_url(
    "MEWE",
    self.ctx.config.get_or("MEWE_HOST", "https://mewe.com"),
)
}

So MEWE_BASE_URL wins over MEWE_HOST, which wins over https://mewe.com.

rustiz providers --status reports MEWE_APP_ID and MEWE_API_KEY (never their values) and marks MeWe configured only when both are non-empty.

Step-by-Step Developer App Setup

1. Join the MeWe Developer Program

Portal: https://developer.mewe.com/developer (developer settings), after reading https://developer.mewe.com/.

MeWe’s Open API is a developer preview. Sign in with a MeWe account, apply to the developer program, and wait for approval. The program is in beta with limited spots; an unapproved account cannot create an app or obtain an API key. There is no self-serve “create app and use it immediately” path.

2. Create a Standalone app

In developer settings, create an application and choose the Standalone type (MeWe also documents Embedded apps and, as “coming soon”, Agents; Rustiz uses the standalone flow because it runs on a backend).

Copy:

  • the App ID → MEWE_APP_ID;
  • the API Key → MEWE_API_KEY.

3. Add the exact redirect URI

generate_auth_url() builds:

{MEWE_HOST}/login?client_id={MEWE_APP_ID}&redirect_uri={urlencode(FRONTEND_URL/integrations/social/mewe)}&state={state}

frontend_url() strips trailing slashes, so register exactly:

{FRONTEND_URL}/integrations/social/mewe

Examples:

https://social.example.com/integrations/social/mewe
http://localhost:5000/integrations/social/mewe

There is no trailing slash after mewe, and the provider does not wrap the URI in redirectmeto.com. Use the same http/https scheme, host and port in the MeWe app settings.

Rustiz sends this exact login URL (the redirect URI is URL-encoded; there is no scope or response_type parameter):

{MEWE_HOST}/login?client_id={MEWE_APP_ID}&redirect_uri={urlencode(FRONTEND_URL/integrations/social/mewe)}&state={state}

4. Configure permissions

The MeWe authorize URL has no scope parameter. Permissions are configured on the app in the developer portal and approved by the user during the login consent screen.

The endpoints Rustiz calls need, at minimum:

CapabilityEndpoint usedPermission family
Read the signed-in profileGET /api/dev/meprofile / basic user info
List groupsGET /api/dev/groupsgroups read (MeWe’s group-agent docs call this user_groups)
Post to the timelinePOST /api/dev/me/postpost/create content (post_content)
Post to a groupPOST /api/dev/group/{groupId}/postgroups + post/create content
Upload a photoPOST /api/dev/photo/uploadphoto/media upload

MeWe’s public docs use permission names such as post_content and user_groups in the Group Agent section. The portal’s exact labels can differ; enable the posting, group and photo-upload permissions the portal offers.

check_scopes() is not called in mewe.rs, and there is no scope check after the callback. A missing app permission appears only when the corresponding API call is made.

5. OAuth flow used by Rustiz

  1. Browser goes to {MEWE_HOST}/login?client_id=...&redirect_uri=...&state=....

  2. The user signs in and approves the app.

  3. MeWe redirects to the callback with loginRequestToken and state:

    https://social.example.com/integrations/social/mewe?loginRequestToken=abc123&state=xyz789
    
  4. Rustiz exchanges the token:

    GET {MEWE_HOST}/api/dev/token?loginRequestToken=abc123
    X-App-Id: <MEWE_APP_ID>
    X-Api-Key: <MEWE_API_KEY>
    

    Successful response:

    { "expiresAt": "2026-12-06T12:00:00.000Z", "apiToken": "..." }
    

    A pending: true response means the user has not approved an in-app request yet; Rustiz returns “Login request is still pending. Please approve on MeWe and try again.”

  5. Rustiz fetches GET /api/dev/me with Authorization: Bearer <apiToken> plus the app headers, and stores userId, name/firstName+lastName, and handle.

6. Sandbox / testing mode vs production

MeWe documents no separate sandbox host for third-party apps. The API communication page lists production as https://mewe.com; it does not document a distinct sandbox base URL. Plan to test with:

  • a dedicated MeWe test account,
  • a test group where the account is a member/admin,
  • and the same production app/API key.

The main gate is the MeWe developer-program application review. Once the developer account and app are approved and the user consents, the same credentials work in production. Publishing/listing the app in the MeWe Store is a separate, optional step.

Important callback mismatch in this port

The generic Rustiz callback (crates/rustiz-server/src/oauth.rs) reads code, state and error from the query:

#![allow(unused)]
fn main() {
struct CallbackQuery {
    code: Option<String>,
    state: Option<String>,
    error: Option<String>,
}
}

MeWe sends loginRequestToken, not code. As written, a real MeWe login lands on /integrations/social/mewe?loginRequestToken=...&state=... and the server responds Missing code or state; rustiz channels connect mewe then waits until its timeout.

Workarounds, in order of preference:

  1. Rewrite the callback URL before it is consumed. After MeWe redirects, change the query key loginRequestToken to code and reload:

    https://social.example.com/integrations/social/mewe?code=abc123&state=xyz789
    

    The state row still exists, and authenticate() receives the login request token as params.code. This is the simplest way to finish the connection with the current binary.

  2. Add a tiny proxy/frontend route at /integrations/social/mewe that forwards loginRequestToken as code to the Rustiz callback.

  3. Patch the server callback to accept loginRequestToken as an alias for code. This is the durable fix, but it is outside this provider’s file.

If MeWe ever returns both code and loginRequestToken, the generic callback works unchanged. The official documented success shape is only loginRequestToken + state.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect mewe

Optional:

rustiz channels connect mewe --open
rustiz channels connect mewe --timeout 600

Do not use --fields or --external-url:

  • --fields is the customFields path; it base64-encodes JSON and passes it to authenticate() as the OAuth code. MeWe expects a loginRequestToken, so the connection fails.
  • --external-url calls external_url(), which MeWe does not implement.

Because of the callback mismatch above, expect the CLI to print the login URL and then time out unless you rewrite the callback query (or use a proxy).

MCP

Start:

{ "provider": "mewe" }

Poll:

{ "connectionId": "Ab12Cd" }

connectChannelTool returns { connected: false, pending: true, connectionId, url }. The human opens the URL. After MeWe redirects, apply the loginRequestToken → code rewrite if necessary, then poll with the same connectionId and check integrationList.

No customFields, externalUrl, name or description for MeWe. custom_fields() is not overridden, so integrationSchema.customFields is an empty array: there are no keys or validation regexes.

Selecting a group (not channelSetupTool)

is_between_steps = false, so the channel is saved immediately and there is no channelSetupTool step. The target is chosen per post with settings.postType / settings.group.

One helper is exposed through triggerTool:

FunctionParametersReturns
groups{} (ignored)[{"id": "<groupId>", "name": "<group name>"}]

groups calls GET /api/dev/groups with the stored bearer token and follows nextPage until it is empty. Any network/status/JSON error stops the loop and returns the groups collected so far, so a partial or empty list does not necessarily mean the account has no groups.

MCP:

{
  "integrationId": "int_...",
  "methodName": "groups",
  "dataSchema": []
}

The returned id is the value to put in settings.group when postType is group. Numeric ids are stringified, so the result is always a string suitable for validate_settings().

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredValidation / behavior
postTypestring enumYes"timeline" or "group".
groupstringConditionalRequired when postType == "group"; minLength: 1. Must be the numeric/string group id, not the name. Ignored for timeline posts.

additionalProperties: true.

Example timeline:

{ "__type": "mewe", "postType": "timeline" }

Example group:

{ "__type": "mewe", "postType": "group", "group": "60ffe9492f19cd2677b0f1a5" }

Use rustiz posts create --json post.json; the simple rustiz posts create --channel ... --content ... form cannot pass provider settings. MCP:

{
  "settings": [
    { "key": "postType", "value": "group" },
    { "key": "group", "value": "60ffe9492f19cd2677b0f1a5" }
  ]
}

validate_settings() rules

  1. postType must be exactly "timeline" or "group".
  2. If postType == "group", group must be a non-empty string.
  3. No other fields are validated.

post() itself defaults a missing postType to "group" and reads group as "", so a direct provider call that bypasses validation can build /api/dev/group//post and fail at MeWe.

Publish behavior

post() uses only the first PostDetails:

  • postType decides the endpoint:

    • timeline → POST {host}/api/dev/me/post
    • group → POST {host}/api/dev/group/{group}/post
  • Body:

    { "text": "<message>", "uploadedPhotoIds": ["<photo id>", "..."] }
    

    uploadedPhotoIds is omitted when there are no photos. MeWe’s reference renders this field as a string in one example; Rustiz sends a JSON array of photo ids, which is the natural shape for multiple photos. If a deployment rejects the array, join the ids into a comma-separated string.

  • The endpoint may answer 204 No Content. The provider calls the raw execute() client (not the retrying JSON client), checks is_success(), and drains the body.

  • MeWe returns no post id, so Rustiz generates a random 12-character id with make_id(12) and stores it as post_id. This is not a real MeWe id.

  • release_url:

    • timeline → https://mewe.com/{integration.profile}/posts
    • group → https://mewe.com/group/{group}

    integration.profile is the stored handle; if it is empty the timeline URL becomes https://mewe.com//posts.

  • Status is PostStatus::Success.

Comments / threads

No. supports_comments = false; comment() is the trait default and returns ProviderError::Unsupported. There is no reply/thread support.

Media requirements

  • No check_validity() override: any media type/count is accepted by Rustiz.

  • The post filters media with:

    #![allow(unused)]
    fn main() {
    m.path.is_empty() || !has_extension(&m.path, "mp4")
    }

    has_extension lowercases the path and checks for the substring .mp4. Therefore:

    • .mp4, .MP4, and even .mp4?x=1 are excluded (treated as video and not uploaded);
    • .mov, .webm, .m4v, .gif, .pdf, and every other extension are treated as photos and sent to /api/dev/photo/upload.
  • Photos are uploaded one at a time (not concurrently) to POST /api/dev/photo/upload as multipart field file. The response id is collected; a response without id contributes an empty string.

  • Each upload failure aborts the post with Photo upload failed: <body>.

  • Alt text and thumbnails are ignored.

  • An empty media path is not filtered out; it is passed to media_stream() and will fail.

Plugs

No @Plug or @PostPlug methods. run_plug() / run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Callback parameter mismatch is the biggest issue. MeWe’s redirect uses loginRequestToken; the generic callback expects code. Without a rewrite or proxy, the connection cannot complete. See the workaround above.
  • No refresh token. refresh_token() returns an all-empty AuthTokenDetails with expires_in: 0. The engine treats an empty access token as a failed refresh, sets refresh_needed, and notifies the user to reconnect. refresh_cron = false, so there is no proactive refresh.
  • Token expiry is derived from expiresAt. A string is parsed as RFC 3339; a number is treated as milliseconds and divided by 1000. If expiresAt is missing or unparsable, the provider assumes 30 days. A past timestamp yields a negative expires_in.
  • A 401 on publish does not trigger a refresh. post() and upload_photo() use the raw execute() client and convert any handled error into ProviderError::Other, losing the refresh classification. Expired tokens therefore surface as retryable failures; reconnect the channel.
  • Publish bypasses the retry client. The 204 response is why post() does not use send_json(), but that also means a 429/500 during publish is not automatically retried. upload_photo() has the same limitation.
  • Synthetic post_id. Because MeWe returns 204, the stored post id is a random Rustiz id, not a MeWe post id. It cannot be used to fetch, comment on, or analyze the real post; release_url is the only link back.
  • Timeline release_url depends on the handle. It uses https://mewe.com/{handle}/posts; a profile without a handle produces a double slash.
  • groups swallows errors. A failed page stops pagination silently. The result can be partial.
  • nextPage is prefixed with the host unconditionally. The code does format!("{host}{p}"); if MeWe ever returns an absolute URL in nextPage, the result becomes https://mewe.comhttps://... and the next request fails.
  • groups ignores its parameters. The function schema has no properties; the MCP dataSchema is ignored. There is no search or page-size control.
  • Only .mp4 is recognized as video. Every other video container is sent to the photo endpoint and rejected by MeWe.
  • The app API key is sent on every authenticated request. MeWe’s docs say X-Api-Key is required only for authorization requests; auth_headers() sends it on GET /api/dev/me, group listing and posts too. It works, but the app secret travels on more requests than necessary.
  • Error mapping is string matching. Unauthorized → refresh, Enhance Your Calm or 420 → retry, Forbidden → bad body. The strings are case-sensitive and are not MeWe error codes. Because post() converts every handled error to ProviderError::Other, a Forbidden on publish is retried as a transient failure rather than failing fast.
  • No documented numeric rate limit. MeWe’s public docs do not publish a requests-per-minute figure. The provider recognizes “Enhance Your Calm” and bodies containing 420 as retryable, but post()/upload_photo() bypass the retrying client, so a 429 during publishing is not retried automatically.
  • picture is always empty. AuthTokenDetails.picture is Some(""), so the channel has no avatar in Rustiz.
  • Name fallback. name is used when non-empty; otherwise the provider joins firstName + lastName and trims the result. Both empty produces an empty channel name.
  • state is only 6 characters. It is stored server-side and consumed once, but it is much shorter than the 32-character state recommended by most OAuth providers.
  • The PKCE verifier is unused. generate_auth_url() stores a random 10-character code_verifier, but MeWe’s login URL has no code_challenge/code_challenge_method and the token exchange never sends it. MeWe’s standalone flow does not use PKCE; the verifier is dead weight.
  • No analytics, mentions, pending posts or plugs. The corresponding trait methods are not overridden.

References

Moltbook (moltbook)

Moltbook is an agent-first social network: accounts are AI agents, each owned by a human who claims it. There is no OAuth app and no developer portal. The server registers an agent with POST /agents/register, the human opens a claim URL and verifies ownership (email + an X post), and the agent’s API key becomes the channel credential.

  • Rust source: crates/rustiz-providers/src/moltbook.rs
  • Upstream port: moltbook.provider.ts
  • Editor: Normal
  • Connection mode: moltbookClaim
  • Related connect flow: crates/rustiz-server/src/connect.rs (start_moltbook, poll_moltbook, connect_moltbook_key)

Overview

PropertyValue
Identifiermoltbook
Display nameMoltbook
Connection modemoltbookClaim (server registers, human claims)
Editornormal
Channel kindsocial
Comments / threadsAdvertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”.
Pending / async publishingNo. supports_pending = false; post() returns Completed per entry
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job100 (the comment says “100 requests/minute”; current write limits are lower)
Character limit (max_length)300 characters, hard-coded
Web3 flagis_web3 = true
ScopesNone
Redirect URINone

Server Environment Variables

required_env() returns nothing:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
MOLTBOOK_BASE_URLNohttps://www.moltbook.com/api/v1API base. Must include /api/v1. Trailing slash trimmed.
MOLTBOOK_PUBLIC_BASE_URLNohttps://www.moltbook.comBase used only to build release URLs (/post/<id>). Not the API base.
JWT_SECRETYesemptyEncrypts the pending agent’s {apiKey, claimUrl, verificationCode} in the oauth_states row

The variable names come from Config::base_url("MOLTBOOK", …) and Config::base_url("MOLTBOOK_PUBLIC", …): the code reads MOLTBOOK_BASE_URL and MOLTBOOK_PUBLIC_BASE_URL, not MOLTBOOK_URL.

Keep www. Moltbook’s documentation warns that the bare https://moltbook.com redirects to www and strips the Authorization header. The default already includes www; if you override MOLTBOOK_BASE_URL, use https://www.moltbook.com/api/v1 exactly.

rustiz providers --status reports moltbook as configured with no environment, because registration needs no server credential.

Step-by-Step Developer App Setup

There is no developer app, no OAuth client and no app review. “Setup” is agent registration plus human claim.

1. Register the agent

Rustiz does this for you, but the underlying call is:

curl -X POST https://www.moltbook.com/api/v1/agents/register \
  -H "Content-Type: application/json" \
  -d '{"name":"MyRustizAgent","description":"Rustiz social media scheduler"}'

The response contains:

{
  "agent": {
    "api_key": "moltbook_xxx",
    "claim_url": "https://www.moltbook.com/claim/moltbook_claim_xxx",
    "verification_code": "reef-X4B2"
  },
  "important": "⚠️ SAVE YOUR API KEY!"
}
  • api_key is shown once. Rustiz encrypts the pending claim’s {apiKey, claimUrl, verificationCode} with JWT_SECRET while it waits for the human; after the claim it becomes the integration’s access token, which is stored as-is in the database like every other provider token.
  • claim_url is for the human owner.
  • verification_code is the code the human includes in the claim tweet.
  • The name is the agent’s identity. Rustiz requires a non-empty name for a new claim (description defaults to Rustiz social media scheduler).

2. Human claims the agent

The owner:

  1. Opens the claim_url.
  2. Verifies an email address (this creates the owner dashboard login).
  3. Posts a verification tweet from X containing the verification_code.
  4. Returns to the claim page / waits for Moltbook to mark the agent claimed.

Until the claim completes, most authenticated endpoints return 401 (“Agent not yet claimed”). The claim link is one-time; after it is used, the owner signs in with email links.

3. Poll the status

The server polls:

curl https://www.moltbook.com/api/v1/agents/status \
  -H "Authorization: Bearer YOUR_API_KEY"

Possible responses:

{ "status": "pending_claim" }
{ "status": "claimed" }

Rustiz treats status == "claimed" or claimed == true as success, then calls GET /agents/me to fetch the profile and saves the channel.

4. Redirect URI and scopes

None. There is no browser redirect, no FRONTEND_URL callback and no scope model. check_scopes() is never called.

5. Sandbox / testing vs production

  • There is no sandbox and no app review. The gate is the human claim.
  • For tests, register a disposable agent and claim it with a test X account. The agent and its posts are real and public within Moltbook.
  • A claim URL/API key that is never claimed is useless; Rustiz’s pending state expires after one hour.
  • Production uses the same flow. The agent must remain claimed; if the owner revokes or deletes it, the API key stops working and the channel must be reconnected.

Connecting a Channel (CLI & MCP)

Flow A — register + claim (normal)

CLI

rustiz channels connect moltbook \
  --name MyRustizAgent \
  --description "Posts release notes for Rustiz"

Output:

{
  "connected": false,
  "pending": true,
  "connectionId": "mb_…",
  "claimUrl": "https://www.moltbook.com/claim/moltbook_claim_xxx",
  "verificationCode": "reef-X4B2",
  "message": "Give this claim URL to the human: …"
}

Give the human claimUrl and verificationCode, then poll:

rustiz channels connect-status mb_…

The connection id lives for one hour. Polling does not extend the TTL (unlike Telegram). If it expires, register again; a new agent/API key is created.

MCP connectChannelTool

{
  "provider": "moltbook",
  "name": "MyRustizAgent",
  "description": "Posts release notes for Rustiz"
}

Returns the same connectionId, claimUrl and verificationCode. Poll with:

{ "connectionId": "mb_…" }

integrationSchema reports:

{
  "type": "moltbookClaim",
  "headless": true,
  "requiresHuman": true
}

Flow B — existing API key (customFields.apiKey)

If the agent is already claimed and you have its API key:

{
  "provider": "moltbook",
  "customFields": {
    "apiKey": "moltbook_xxx"
  }
}

CLI:

printf '%s' '{"apiKey":"moltbook_xxx"}' \
  | rustiz channels connect moltbook --fields -

The server calls GET /agents/me with the key and connects immediately. The key can be apiKey or api_key (both accepted). If the agent is not claimed yet, /agents/me returns 401 and the connect call fails; finish the claim first.

Provider functions (triggerTool)

None. There is no “list submolts” tool; the default general submolt is used unless the post settings specify another name.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "submolt": {
      "type": "string",
      "description": "Submolt (community) to post to, for example `general`. Defaults to `general` when omitted."
    }
  },
  "additionalProperties": true
}

submolt is a name (“general”, “aithoughts”, …), not a numeric id.

validate_settings()

Only one rule:

  • If submolt is present and not null, it must be a string.

It does not check that the submolt exists, that the agent is a member, or that the name is non-empty. An empty string is falsy in post() and falls back to general.

check_validity()

Not overridden. There are no pre-flight media rules; media is ignored completely.

How a post is built

post() loops over every PostDetails in the input:

#![allow(unused)]
fn main() {
let submolt = post.settings["submolt"].as_str()
    .filter(|s| !s.is_empty())
    .unwrap_or("general");
let title: String = post.message.chars().take(100).collect();

POST {api_base}/posts
{
  "submolt": submolt,
  "title": title,
  "content": post.message
}
}
  • The first 100 characters of the message become the title.
  • The full message is the body.
  • The API documents submolt_name as preferred and submolt as an accepted alias; Rustiz sends submolt.
  • The API documents title max 300 chars and content max 40,000 chars. Rustiz’s max_length is 300 for the whole message, so it never gets near the content limit and truncates the title to 100 anyway.
  • There is no link-post support (url, type: "link"), no image upload, no alt text and no media at all. A post with images publishes as text only.
  • Moltbook may return a verification object with an AI/math challenge before a post becomes visible. Rustiz does not handle this challenge: it only checks success and reads post.id. A challenged post may be created but invisible, or the create call may fail; check the Moltbook response if a post never appears.

How a comment is built

comment() loops over every PostDetails:

POST {api_base}/posts/{post_id}/comments
{ "content": "<message>" }

If last_comment_id is non-empty, it adds:

{ "parent_id": "<last_comment_id>" }
  • Top-level comments have no parent_id.
  • Replies chain through last_comment_id; the engine only supplies it when publishing a multi-entry thread.
  • supports_comments = false, so the engine truncates threads before this code runs. Manual provider calls would work.
  • The release URL for a comment is the post URL, not a comment permalink. post_id is the comment id.

Media requirements

None. Media is not read, validated or uploaded. The only content is text. This makes Moltbook one of the few providers where a post with images silently publishes as a text-only post rather than failing.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

Implementation Notes & Gotchas

  • register_agent() requires success: true. The code checks response["success"].as_bool().unwrap_or(false) and returns Registration failed otherwise, then reads response["agent"]. The official Moltbook skill’s registration sample shows only { "agent": {...}, "important": "..." }, with no top-level success. If the live API follows that sample exactly, every new registration fails in Rustiz. Some API summaries show {"success": true, "data": {"agent": ...}}; Rustiz does not look under data. Verify the raw response with the curl command above before relying on the claim flow.
  • get_agent_profile() also requires success: true. /agents/me sample responses do include success: true and agent, so this is usually fine. check_agent_status() does not check success; it only reads status, so the claim poll works with the documented { "status": "pending_claim" } shape. post() and comment() use the same success + nested-object pattern; a different write envelope fails those calls even if the object was created.
  • The API key is the identity. authenticate() sets id to the agent’s name when the profile has one, not the numeric agent id. The name becomes integration.internal_id. Renaming the agent outside Rustiz changes the identity; a reconnect creates a new channel rather than updating the old one. If the name is empty, it falls back to profile.id.
  • The claim TTL is one hour and is not refreshed. poll_moltbook() reads the state but never calls put_pending() again. A slow human claim loses the pending API key; the agent must be registered again (new name, new key).
  • Claim status is checked leniently. status["success"] == Some(false) and status["status"].is_null() is the only hard error. Any other shape is treated as “not claimed yet”, including an unexpected error body. A malformed status response can leave the agent polling until the TTL expires.
  • max_concurrent_job = 100 is not a rate limit. Moltbook documents 60 read requests per 60 seconds and 30 write requests per 60 seconds. The Rust comment says “100 requests/minute”, which matches neither. A concurrency of 100 can easily produce 429s; the generic HTTP layer retries 429 up to three times, but a burst can still fail.
  • max_length = 300 is far below the content limit. Moltbook allows a 40,000-character body and a 300-character title. Rustiz limits the entire message to 300 characters, so long-form Moltbook posts are impossible and a post that Moltbook would accept as a body is rejected locally. The current API accepts a 300-character title and a 40,000-character body; the Rust max_length of 300 is far more restrictive.
  • The title is a 100-character slice of the message. A message with a long first sentence produces a title that cuts mid-word. There is no separate title field in the settings schema.
  • supports_comments = false despite a working comment(). The engine truncates a multi-entry post to the first entry and MCP validation reports “Moltbook does not support thread replies”. If you need comment threads, set info.supports_comments = true; the provider code already supports parent_id.
  • No refresh. The API key does not expire. refresh_token() returns an empty token; a 401 is treated as a refresh/reconnect by the generic layer. Reconnect with customFields.apiKey after the agent is claimed.
  • No profile pictures. authenticate() sets picture: Some(""); the channel list shows no avatar even if Moltbook has one.
  • The name is required for registration. start_moltbook() rejects a missing/blank name before calling the API: Moltbook needs an agent name to register. description is optional and defaults to Rustiz social media scheduler.
  • customFields takes precedence. If an object is passed to connectChannelTool, connect.rs routes to connect_moltbook_key() and ignores name/description. Send either {apiKey} or {name, description}, never both expecting a new registration.
  • A failed registration may still create an agent. If Moltbook creates the agent but the response shape fails Rustiz’s success check, the API key is lost (shown once). Check the Moltbook dashboard / registration logs before retrying, or you may accumulate orphan agents.
  • Public release URLs. post() builds {MOLTBOOK_PUBLIC_BASE_URL}/post/{id}; comment() builds {MOLTBOOK_PUBLIC_BASE_URL}/post/{post_id}. Overriding the public base only changes links, not API calls.
  • is_web3 = true. connect.rs special-cases moltbook before the generic web3 rejection, so it is supported. The flag is mostly informational.
  • No analytics, mentions or functions. All capability flags are false; functions() is empty.

References

Nostr (nostr)

Nostr is a decentralized protocol, not a company: there is no developer portal, no OAuth app and no app review. A “channel” is a secp256k1 keypair plus a relay list. Rustiz accepts the 64-character hex private key, signs a kind-1 text note with BIP-340 Schnorr, and broadcasts it to a fixed set of public relays over WebSockets.

  • Rust source: crates/rustiz-providers/src/nostr.rs
  • Upstream port: nostr.provider.ts
  • Editor: Normal
  • Connection mode: customFields
  • Tool tip: “Make sure you private a HEX key of your Nostr private key, you can get it from websites like iris.to” (the upstream typo is preserved in the Rust string).

Overview

PropertyValue
Identifiernostr
Display nameNostr
Connection modecustomFields (hex private key)
Editornormal
Channel kindsocial
Comments / threadsAdvertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”.
Pending / async publishingNo. supports_pending = false; post() broadcasts and returns Completed
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job5
Character limit (max_length)100000 characters, hard-coded
ScopesNone
Redirect URINone
Relay default5 public relays (see below)

Server Environment Variables

required_env() returns nothing:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
JWT_SECRETYes in practiceemptySigns the HS256 “access token” JWT that wraps the Nostr private key. If it changes, every Nostr channel must reconnect.
NOSTR_RELAYSNo5 built-in relaysComma-separated relay URLs. The code comment says it exists so tests can point at a local WebSocket server, but it is a real runtime override in release builds.
DISABLE_SSRF_PROTECTIONNounsetNot read by Nostr itself. Relay connections use tokio-tungstenite directly, not the shared SSRF-safe HTTP client, so private relays are reachable without this switch.

There is no NOSTR_BASE_URL. Relays are WebSocket URLs, configured by NOSTR_RELAYS or the constant list.

Default relays, in order:

wss://nos.lol
wss://relay.damus.io
wss://relay.snort.social
wss://temp.iris.to
wss://vault.iris.to

rustiz providers --status reports nostr as configured with no environment at all, because credentials arrive per channel. It cannot detect a missing JWT_SECRET; the provider still signs with an empty HMAC key (insecure but functional).

Step-by-Step Developer App Setup

There is no developer app, no portal and no review.

1. Obtain the hex private key

  1. Open a Nostr client that can export the key, for example https://iris.to or any NIP-19-aware client.
  2. Export the hex private key. It must be exactly 64 hexadecimal characters (32 bytes), with no 0x, no spaces and no newline.
  3. Do not paste an nsec1… bech32 key. Rustiz does not decode bech32; see the gotchas below.

The corresponding public key is derived with BIP-340 Schnorr (k256::schnorr::SigningKey). The public key is the channel’s internal id and is also used as the authors filter when publishing.

2. Choose relays

The provider ships a fixed list of five public relays. Override it for a private relay, a test relay, or a region-specific set:

NOSTR_RELAYS="wss://relay.example.com,wss://nos.lol"

Notes:

  • The list is split on ,, each entry trimmed; empty entries are skipped.
  • Every publish opens a new WebSocket per relay; more relays means slower publishing and more failure points.
  • There is no NIP-65 relay-list discovery. The channel does not read the user’s kind-10002 relay list; it uses this server-side list for every channel.

3. Redirect URI and scopes

None. Nostr has no OAuth, no browser redirect and no scope model. The private key is the credential. check_scopes() is never called.

4. Sandbox / testing vs production

  • There is no sandbox and no app review. The same key works on mainnet relays immediately.
  • For tests, run a local relay (for example strfry, nostr-rs-relay, or a disposable public relay), set NOSTR_RELAYS to it, and use a throwaway key. Remember that NOSTR_RELAYS is server-wide: changing it changes where every connected channel publishes.
  • Mainnet relays enforce their own policies (spam filters, proof-of-work, paid relays, rate limits, size limits). A relay may accept the WebSocket connection and silently drop the event; Rustiz does not surface per-relay rejections.
  • A test note is public to anyone who can read the relay. Do not test with a production identity unless you intend to publish.

Connecting a Channel (CLI & MCP)

customFields key

KeyTypeDefaultValidation regex (exact string)Notes
passwordpassword—/^.{3,}$/64-character hex private key. The label is Nostr private key; the key name is password for historical reasons.

The validation regex is misleading: it accepts any 3+ characters, including nsec1…, 0x…, or a short string. Only authenticate() enforces 32 bytes of hex, and it reports the failure as the generic Invalid credentials.

CLI

{ "password": "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa" }
rustiz channels connect nostr --fields ./nostr-key.json

# or stdin
printf '%s' '{"password":"67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"}' \
  | rustiz channels connect nostr --fields -

Immediate result:

{ "connected": true, "pending": false, "name": "Alice" }

The name comes from the kind-0 metadata event found on the first relay that returns one; if none is found, it is No Name and the username is nousername.

MCP connectChannelTool

{
  "provider": "nostr",
  "customFields": {
    "password": "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"
  }
}

integrationSchema reports:

{
  "type": "customFields",
  "headless": true,
  "requiresHuman": false
}

There is no follow-up. Call integrationList for the channel id.

Important: do not omit customFields

Without customFields, connect.rs falls through to the OAuth path. Nostr::generate_auth_url() returns the random state as both url and state, so the agent gets a “login URL” that is not a URL and no channel is created. This is the same legacy stub as Bluesky. Always send the key object.

Provider functions (triggerTool)

None. functions() is empty.

Post Settings & Media Validation

settings_schema()

Nostr does not override the trait default:

{ "type": "object", "properties": {}, "additionalProperties": true }

There are no provider-specific settings. __type is added by the engine. Relays, tags and event kind are not configurable per post.

validate_settings()

Not overridden: every settings object is accepted.

check_validity()

Not overridden: no pre-flight media or length checks beyond max_length.

Media requirements

Nostr does not upload media. build_content() appends public URLs to the note body:

#![allow(unused)]
fn main() {
let media_content = post.media.iter()
    .map(|m| m.path.clone())
    .collect::<Vec<_>>()
    .join("\n\n");

if media_content.is_empty() {
    post.message.clone()
} else {
    format!("{}\n\n{}", post.message, media_content)
}
}

Consequences:

  • Every media path must be a public URL; a local path is published as text.
  • No alt text, thumbnail or MIME handling.
  • No image/video upload, no NIP-94 file metadata, no NIP-92 imeta tags.
  • Clients may render a URL preview if the URL is an image/video, but that is a client decision, not part of the event.
  • An empty message with media produces content that starts with two newlines.

Threads

post() always sends tags: []. A NIP-01 kind-1 note with no e/p tags is a root note.

comment() sends:

[
  ["e", "<reply_to>", "", "reply"],
  ["p", "<integration pubkey>"]
]
  • reply_to is last_comment_id when present, otherwise the root post_id.
  • The p tag is the channel’s own pubkey (id is integration.internal_id), not the parent author’s pubkey. It marks the author, not the person being replied to.
  • There is no ["e", root, "", "root"] tag. For a two-level thread this is usually fine; for deeper threads, clients that follow NIP-10 markers may attach the reply to the wrong root.
  • supports_comments is false, so the engine never calls comment() for a multi-entry post in practice. A manual provider-level call would work, but MCP validatePostTool rejects thread replies.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

Implementation Notes & Gotchas

  • Hex only, no nsec. secret_key() splits the string into 2-character chunks and parses each with u8::from_str_radix(…, 16). It requires exactly 64 hex characters. nsec1… bech32, 0x…, uppercase NSEC, whitespace and newlines all fail. NIP-19 says clients should accept both hex and nsec; this port does not decode bech32.
  • Validation lets bad keys through. custom_fields().validation is /^.{3,}$/, so the connect form accepts an nsec or a 10-character string. The failure surfaces only after submission as Invalid credentials, because authenticate() maps every key-derivation error to that message.
  • The access token is a JWT containing the private key. jwt_sign() builds an HS256 JWT with payload { "password": "<hex private key>", "iat": … }, base64url-encoded, and stores it as the integration’s access_token. Anyone with database read access or the JWT_SECRET can recover the Nostr private key. Treat the Rustiz database and JWT_SECRET as equivalent to the key itself.
  • The JWT has no expiry. two_hundred_years() sets the metadata expires_in, but the JWT payload has only password and iat; there is no exp claim, and jwt_verify() does not check one. The token is valid until JWT_SECRET changes or the channel is deleted.
  • Changing JWT_SECRET breaks every channel. jwt_verify() uses the current secret; old tokens fail with Invalid Nostr token, please reconnect the channel. There is no key rotation or re-encryption path.
  • refresh_token() is a no-op. It returns an empty token with expires_in: 0. A 401/403 from a relay is not a token problem; it is usually a relay policy or a deleted event. Reconnect only if the JWT itself fails.
  • Publishing is fire-and-forget. publish() opens a socket to each relay, sends ["REQ", sub, {kinds:[1], authors:[pubkey]}] and then ["EVENT", event], and waits up to 6 seconds for the relay to echo the event back. It returns the first echoed event id, or "". post() always returns PostStatus::Completed, even when every relay failed.
  • Empty release URL. If no relay echoes the event, post_id is empty and release_url is https://primal.net/e/. The post may still have been accepted by a relay that did not echo within 6 seconds. There is no reconciliation or “missing content” lookup.
  • Sequential relay I/O is slow. publish() and query_first() iterate the relay list one at a time. Each relay costs up to 10 s to connect plus up to 6 s waiting for EVENT/EOSE. With five relays, a publish can take up to ~80 s if every socket connects slowly and no relay echoes; authentication’s kind-0 lookup has the same worst case. HTTP request timeouts and the engine’s job timeouts must accommodate this.
  • NOSTR_RELAYS is global. There is no per-channel relay list. Two channels cannot publish to different relays from one deployment.
  • No relay authentication. NIP-42 AUTH is not implemented. Paid or private relays that require authentication will close the connection or reject the event.
  • No NIP-65 / NIP-02 discovery. The provider does not read the user’s relay list or contact list. Profile lookup is a single kind-0 query per relay, and only the first event with a usable name wins.
  • Profile lookup is best-effort. find_relay_information() tries relays in order, parses content as JSON, and accepts name, displayName or display_name. If no relay answers, the channel name is No Name, username nousername, picture empty. That is a valid connection; the profile can be fixed later by editing kind-0 metadata outside Rustiz and reconnecting.
  • Comment p tag is self-referential. As noted above, ["p", id] uses the integration’s own pubkey. A proper NIP-10 reply should tag the parent event’s author. This does not prevent publication, but it may suppress notifications to the person being replied to.
  • No root marker. Deeper replies lack the root marker. Some clients reconstruct the thread from the e chain; others show the reply as a top-level note.
  • max_length = 100000 is not a relay limit. Most public relays cap event size well below 100 KB (often 64 KB or less) and may reject large content with a NOTICE that Rustiz ignores. Media URLs make long notes easy to hit that cap.
  • No per-relay error reporting. publish_to_relay() returns Err(()) for connection/send failures and the caller ignores it. There is no log line per failed relay, no retry and no user-visible warning.
  • No rate limiting. max_concurrent_job = 5 is a concurrency cap, not a relay-aware rate limiter. Public relays commonly rate-limit or ban keys that publish in bursts.
  • is_web3 is false. Unlike Farcaster/Moltbook, Nostr is not marked web3, so connect.rs does not reject it in the generic web3 branch.
  • Release URL is Primal-specific. https://primal.net/e/{eventId} assumes Primal can resolve the event; other clients use njump.me, habla.news, etc. The event id is the canonical identifier.

References

Pinterest (pinterest)

Pinterest uses API v5 at api.pinterest.com with OAuth 2.0. Rustiz publishes image pins, multi-image pins and video pins to a board the user selects per post (the board is a post setting, not a channel setting). Video pins are uploaded to Pinterest’s S3 upload URL first, then attached by media_id with a cover image.

  • Rust source: crates/rustiz-providers/src/pinterest.rs
  • Upstream port: pinterest.provider.ts
  • Editor: Normal
  • Connection mode: oauth
  • Related provider: none

Overview

PropertyValue
Identifierpinterest
Display namePinterest
Connection modeoauth (Authorization Code grant)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first PostDetails is published and Pinterest has no pin-comment API
Pending / async publishingYes. supports_pending = true; video uploads are polled, then the pin is created behind an arm → confirm → publish handshake
AnalyticsYes. supports_analytics = true; account analytics
Post analyticsYes. supports_post_analytics = true; pin analytics
MentionsNo. supports_mentions = false
is_between_stepsfalse — no setup step; the board is chosen in each post’s settings
convert_to_jpegfalse
max_concurrent_job10 (“Pinterest limits are per user”)
Character limit (max_length)500 characters (the pin description; Pinterest itself allows 800)
API basehttps://api.pinterest.com (PINTEREST_BASE_URL)
Web basehttps://www.pinterest.com (PINTEREST_WEB_BASE_URL)
Token refreshReactive on 401; refresh_cron is false

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
PINTEREST_CLIENT_IDYes—App ID from My apps. Doubles as the Basic-auth username for /v5/oauth/token.
PINTEREST_CLIENT_SECRETYes—App secret key. Doubles as the Basic-auth password.
PINTEREST_BASE_URLNohttps://api.pinterest.comAPI origin. Set to https://api-sandbox.pinterest.com only for sandbox testing.
PINTEREST_WEB_BASE_URLNohttps://www.pinterest.comOrigin of /oauth/ and of https://www.pinterest.com/pin/{id} release URLs.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Pinterest fetches image URLs; video bytes are uploaded directly by Rustiz.

rustiz providers --status checks only PINTEREST_CLIENT_ID and PINTEREST_CLIENT_SECRET. It cannot tell whether the app is approved for Trial or Standard access, which changes rate limits and pin visibility.

Step-by-Step Developer App Setup

1. Create a Pinterest business account and app

  1. Log in to Pinterest with a business account (or convert the account). Verify the account email.
  2. Go to https://developers.pinterest.com/apps/ and accept the Developer Terms of Service.
  3. Select Connect app, fill in the app details (name, description, use case, privacy policy URL) and submit the request for Trial access.
  4. Requests are reviewed by Pinterest (usually each business day). You receive an email when approved or denied.
  5. Once approved, open My apps → Manage → Configure and copy the App ID and App secret key.

App name rules: at least three Latin letters or numbers, and it must not contain the word “Pinterest”.

2. Redirect URI (exact)

Pinterest::redirect_uri() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/pinterest", self.ctx.config.frontend_url())
}

Examples:

https://social.example.com/integrations/social/pinterest   # production
http://localhost:5000/integrations/social/pinterest        # Docker / rustiz serve
http://localhost:4200/integrations/social/pinterest        # upstream Postiz dev UI

There is no redirectmeto.com wrapper and no trailing slash. Pinterest accepts multiple redirect URIs and requires the redirect_uri sent to the authorize and token endpoints to match exactly one registered value.

3. Scopes requested and checked

Pinterest::scopes() returns:

#![allow(unused)]
fn main() {
vec![
    "boards:read",
    "boards:write",
    "pins:read",
    "pins:write",
    "user_accounts:read",
]
}

The authorize URL joins them with commas:

https://www.pinterest.com/oauth/
  ?client_id={PINTEREST_CLIENT_ID}
  &redirect_uri={redirect_uri}
  &response_type=code
  &scope=boards:read,boards:write,pins:read,pins:write,user_accounts:read
  &state={state}

After the token exchange, authenticate() calls:

#![allow(unused)]
fn main() {
check_scopes(&self.info.scopes, token["scope"].as_str().unwrap_or_default())
}

All five must be granted. check_scopes() splits the returned scope string on comma or space and requires exact matches.

ScopeWhy Rustiz needs it
boards:readList boards for the board picker
boards:writeRequired by POST /v5/pins even for an existing board
pins:readRead media upload status and pin analytics
pins:writeCreate pins and register video media
user_accounts:readRead /v5/user_account after connect and before create

4. Token flow performed by Rustiz

  1. Authorize at https://www.pinterest.com/oauth/ with response_type=code.
  2. POST https://api.pinterest.com/v5/oauth/token with Authorization: Basic base64(client_id:client_secret) and Content-Type: application/x-www-form-urlencoded: grant_type=authorization_code, code, redirect_uri.
  3. Read access_token, refresh_token, expires_in, scope.
  4. GET /v5/user_account with Authorization: Bearer <token>; store id, username, profile_image, token and refresh token.

Pinterest access tokens last about 30 days; the continuous refresh token lasts 60 days and is refreshable indefinitely. The token response includes refresh_token_expires_in/refresh_token_expires_at, but Rustiz only stores the generic expiry.

5. Trial access vs Standard access

CapabilityTrial accessStandard access
Default for a new appYes, after reviewRequires an upgrade request
Pin analyticsYesYes
Reading boards/pinsYesYes
Creating boards/pinsYes, visible only to the creator (“Sandbox entities”)Yes, public according to the board
Rate limits1,000 requests/day per app; category daily limits (e.g. org_write 300/day)100 requests/second per user per app; category per-minute limits (e.g. org_write 100/min)
Production useNot recommendedRecommended

The Trial trap: a pin created with Trial access is visible only to the account that created it. It looks like a successful post in Rustiz but nobody else can see it. If your posts “work” but never appear publicly, check the app’s access tier before debugging the provider.

Upgrade to Standard:

  1. The app must already be approved for Trial access and comply with the Developer Guidelines.
  2. Prepare a video recording of the app completing the OAuth flow and a real pin creation. Terminal/Postman recordings are accepted if you are the only user.
  3. My apps → Upgrade, verify the app details and privacy policy, upload the video, submit.
  4. Standard requests are reviewed regularly; you receive an email decision.

Common denial reasons: privacy policy not publicly accessible, vague app description, demo does not show the OAuth flow, demo does not show a live Pinterest integration.

6. Pinterest Sandbox

Pinterest has a real sandbox at api-sandbox.pinterest.com:

  • Sandbox tokens are separate from production tokens. You can generate a 30-day sandbox token on My apps → Manage → Configure → Generate Access Token → Sandbox, or run the OAuth flow against https://api-sandbox.pinterest.com/v5/oauth/token.
  • Sandbox entities (boards/pins/ad accounts) are separate from production.
  • Video pins are not supported in Sandbox.
  • The sandbox supports pins, boards, media, user accounts, analytics and ads endpoints listed in Pinterest’s Sandbox docs.

Rustiz has no built-in sandbox switch. You can set PINTEREST_BASE_URL=https://api-sandbox.pinterest.com, but the OAuth flow then also uses the sandbox host while PINTEREST_WEB_BASE_URL remains the production login page. The clean way to test is to obtain a sandbox token out-of-band and use a test deployment; the provider itself expects a production OAuth token. For most self-hosted installs, Trial access against production with a test account is simpler.

7. App review / production checklist

  • Trial access is itself reviewed; there is no fully self-serve production access.
  • Standard access requires the video demo described above.
  • A privacy policy hosted on a domain associated with the app is mandatory.
  • Pinterest participates in GitHub secret scanning: a leaked token is revoked within 24 hours and the account must be reconnected.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect pinterest
rustiz channels connect pinterest --open
rustiz channels connect pinterest --timeout 600

The login URL goes to stderr. With a local FRONTEND_URL the CLI serves the callback and waits; with a public HTTPS FRONTEND_URL it prints the URL and exits.

is_between_steps is false, so the channel is complete after the callback. There is no channelSetupTool step; board selection happens per post.

MCP connectChannelTool

{ "provider": "pinterest" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.pinterest.com/oauth/?client_id=…&redirect_uri=…&response_type=code&scope=boards:read,boards:write,pins:read,pins:write,user_accounts:read&state=…",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with { "connectionId": "…" }. integrationSchema reports oauth, headless: false, requiresHuman: true, requiresExternalUrl: false.

Listing boards: triggerTool

After the channel is connected, list boards:

{
  "integrationId": "<pinterest channel id>",
  "methodName": "boards",
  "dataSchema": []
}

pinterest exposes one function:

{
  "name": "boards",
  "description": "List of boards",
  "input_schema": { "type": "object", "properties": {} }
}

Result:

[
  { "name": "Weeknight dinners", "id": "987654321012345678" },
  { "name": "Studio", "id": "123456789012345678" }
]

The id is numeric and is what settings.board expects. Pinterest board ids are snowflake-like 18–19 digit strings; keep them as strings to avoid JavaScript precision loss.

Then publish with settings.board set to that id.

What not to send

  • customFields routes to connect_custom() and is treated as an OAuth code. Never send it to pinterest.
  • externalUrl is unused.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "board": {
      "type": "string",
      "description": "The numeric id of the board (from the boards list of the channel), not the board name"
    },
    "title": {
      "type": "string",
      "maxLength": 100,
      "description": "Optional pin title, at most 100 characters."
    },
    "link": {
      "type": "string",
      "description": "Optional destination URL the pin links to."
    },
    "dominant_color": {
      "type": "string",
      "description": "Optional dominant color of the pin (hex color)."
    }
  },
  "required": ["board"],
  "additionalProperties": true
}
FieldRequiredBehaviour
boardYesNumeric board id. The provider rejects a board name with a helpful message.
titleNoPin title, max 100 characters. Sent only when non-empty.
linkNoDestination URL. Sent only when non-empty. Must be http(s).
dominant_colorNoHex colour string, sent only when non-empty. No hex validation.

Not exposed: board_section_id, alt_text, per-item carousel titles/links, parent_pin_id, is_removable, AI disclosures, and cover_image_key_frame_time. The description is the post message.

validate_settings()

  1. board must be present, non-empty, and all ASCII digits. A board name fails with “Board must be the numeric board id (use the boards list of the channel to find it), not the board name”.
  2. title ≤ 100 characters (Unicode scalar count).
  3. link, when non-empty, must parse as http/https.
  4. dominant_color, when non-null, must be a string.
  5. No check on the message length here; max_length is 500.

Pinterest’s own limits are: title 100, description 800, link 2,048, alt text 500. Rustiz caps the description at 500, so a valid 600-character Pinterest description is rejected locally. Raise max_length() if you need the full 800.

check_validity()

The validator is opinionated about video and multi-image pins:

  1. At least one media: “Requires at least one media”.
  2. At most 5 media items: “You can only have up to 5 media items”.
  3. If a video (.mp4 in the path) is present:
    • the set must include a non-video item, otherwise “If posting a video you have to also include a cover image as second media”;
    • more than 2 items is rejected: “If posting a video you can only have two media items”.
  4. If there is more than one item and all are images, every image must have exactly the same width and height: “Requires all images to have the same width and height”. Dimensions are read with MediaContext::image_dimensions, which may read local files or fetch remote URLs.

Two-video edge case: the first video check only fails when there is no non-video item and the count is not 2. Two videos (no image) pass the validity check, then post_pending() finds no cover image, sends a video_id source without cover_image_url, and Pinterest returns 400. Always include exactly one image cover with a video.

Rustiz does not validate:

  • image format (Pinterest accepts BMP/JPEG/PNG/TIFF/WebP up to 20 MB);
  • recommended 2:3 ratio / 1000×1500;
  • video duration (Pinterest organic video is commonly 4 s–15 min, some docs say 5 min), codec, or 2 GB size;
  • title/description against Pinterest’s actual API limits beyond the local checks above.

Media requirements

MediaHow Rustiz sends itNotes
Single imagemedia_source: {source_type: "image_url", url}Public URL; Pinterest cURLs it
Multi-imagemedia_source: {source_type: "multiple_image_urls", items:[{url}, …]}2–5 items, equal dimensions (local check)
VideoPOST /v5/media {media_type:"video"} → POST the returned upload_url as multipart with the returned upload_parameters plus a file part → poll /v5/media/{id} until succeeded → POST /v5/pins with media_source: {source_type:"video_id", media_id, cover_image_url}Requires an image cover as a second media item. The file is streamed from Rustiz, unlike images
  • Pinterest image pins accept BMP, JPEG, PNG, TIFF and WebP up to 20 MB; 2:3 (1000×1500) is the recommended ratio.
  • Organic video pins: MP4/MOV/M4V, H.264/H.265, roughly 4 s–15 min, up to 2 GB; 9:16 or 1:1/2:3/4:5 recommended.
  • The video upload file part has no explicit MIME type and no filename; S3 usually accepts it. If Pinterest rejects the upload, add a filename and video/mp4 content type.
  • The cover image is sent as cover_image_url and must be publicly reachable. Rustiz does not support cover_image_key_frame_time, so a video without a cover image cannot be published.

Threads / comments

supports_comments = false. Only the first PostDetails is published. A multi-entry post does not create multiple pins, and Pinterest has no comment-creation API. Extra entries are ignored.

Plugs

No @Plug or @PostPlug methods. run_plug() / run_post_plug() return ProviderError::Unsupported. The boards helper is a dashboard function, not a plug.

Implementation Notes & Gotchas

  • The board is per post, not per channel. is_between_steps is false; there is no board setup step. settings.board is required on every post and is copied into PendingData.settings.board before the async flow.
  • Board list is only the first page. boards() calls GET /v5/boards?page_size=250 and never follows the bookmark. An account with more than 250 boards will not see the rest in triggerTool. Add pagination if that matters.
  • No board sections. board_section_id is never sent. Pins always go to the board root.
  • Arm → confirm → publish. Pinterest has no idempotency key, so finalize_post() refuses to create a pin unless attempting=true and confirmed=true. The first finalize arms; the next check_post_status witnesses; only the following finalize sends POST /v5/pins. If a run dies after the create, the next check returns “Pinterest may have already published this pin” rather than risk a duplicate.
  • Token probe before arming. For image-only pins there is no API call between arming and the create, so an expired token would 401 at the create and the retry would see an armed state and report a false duplicate. finalize_post() therefore calls fetch_user(access_token) before setting attempting=true. A refresh can happen at that point and the armed state is not yet persisted.
  • Video flow is separate from the pin flow. post_pending() uploads the video immediately and stores media_id; check_post_status() polls GET /v5/media/{id}:
    • failed → “The file is corrupted and cannot be uploaded” (bad body);
    • not succeeded → Pending;
    • succeeded → witness and Ready. A transient status-read error is ignored and retried; a RefreshToken error is propagated.
  • Image-only pins skip processing. media_id is empty, so check_post_status() goes straight to witness(). There is still an arm → confirm cycle; the first check returns Ready with confirmed=true, and the next finalize creates the pin. The blocking post() path sleeps 20 s between checks.
  • refresh_token rotation bug. Pinterest returns a new continuous refresh token on every refresh, but refresh_token() stores Some(refresh_token.to_string()) — the old token — instead of token["refresh_token"]. The new access token works, but the rotated refresh token is discarded. Once the old refresh token expires (or is invalidated by rotation), the next refresh fails and the channel must be reconnected. The fix is to store token["refresh_token"] when present.
  • Refresh sends redirect_uri. Pinterest’s refresh grant does not require redirect_uri, but Rustiz includes it (and the full scope list). Pinterest currently ignores the extra field; if a future version rejects unknown parameters, remove it.
  • No proactive refresh. refresh_cron is false; the hourly sweep will not refresh Pinterest before expiry. Refresh is triggered reactively by a 401 from any call, through the shared HTTP layer. If a channel is idle for more than 30 days, the first post after the gap triggers the refresh.
  • expires_in is stored as-is. Pinterest returns 2592000 (30 days). The engine uses this for tokenExpiration; it does not add a safety margin.
  • Analytics clamp to 90 days. analytics() clamps days to 0..=89 and sends start_date/end_date as YYYY-MM-DD. Pinterest only serves the last 90 days. It builds five series: Pin click rate, Impressions, Pin Clicks, Engagement, Saves. A day is only emitted when PIN_CLICK_RATE is present in that day’s metrics, so sparse accounts get gaps.
  • Post analytics ignore from_date. post_analytics() always requests the last 89 days with metric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVE and reads all.summary_metrics. It emits one point per metric dated today; percentage change is 0.0. Errors are logged and return [].
  • Description vs max_length. The engine caps the post body at 500, below Pinterest’s 800-character description limit. The message is sent verbatim as description; no HTML stripping is applied (the editor is Normal).
  • No HTML. Editor::Normal means the engine sends plain text. Pinterest does not render HTML.
  • Video cover must be a separate media. There is no setting for a cover; the second media item is used. A video pin with 3+ media is rejected by check_validity even though the API could ignore the extras.
  • Error mapping. constraint: maxItems=5 → max 5 images; “Something went wrong on our end” and “Unable to reach the URL” → retry; board-id regex failure → “board ID must be a numeric string”; “Board not found” → check the id; “You are not permitted to access that resource” → the account cannot write to that board; “cover_image_url or cover_image_content_type” → a video needs a cover image. A bare 401 triggers the generic refresh path.
  • Trial pin invisibility. The most confusing failure mode: Trial access creates pins visible only to the creator. Rustiz returns success because the API returned success. Check the app tier and the logged-in account before investigating the provider.
  • Rate limits. Trial: 1,000 requests/day per app, org_write 300/day. Standard: 100 requests/second per user per app, org_write 100/min, org_read 1,000/min. Responses include x-ratelimit-* headers; the provider does not read them or back off. max_concurrent_job = 10 is still enough to hit the trial daily cap quickly when each post costs a board read, an upload and a create.
  • Sandbox has no video pins. A sandbox test of the video flow cannot succeed; test video against production with a Trial/Standard app.
  • Secret scanning. Pinterest revokes leaked tokens found by GitHub secret scanning within 24 hours. Do not commit .env; rotate the app secret from My apps → Configure → Reset app secret if exposed. Existing access tokens keep working after a secret reset, but new tokens cannot be minted with the old secret.

References

Reddit (reddit)

Reddit is a community platform where every post targets one subreddit. The Rustiz provider publishes one subreddit per async step, uploads images and MP4 videos to Reddit’s S3-backed media endpoint, and can attach a required link flair. Comments are published as top-level replies on the submission.

  • Rust source: crates/rustiz-providers/src/reddit.rs
  • Upstream port: reddit.provider.ts
  • Editor: Normal
  • Connection mode: oauth

2026 status — read this before creating an app. Reddit ended self-service Data API access in November 2025. New OAuth apps need explicit approval under the Responsible Builder Policy, and in 2026 Reddit announced that public Data API apps are being migrated to the Developer Platform (Devvit). Apps that have not registered or engaged start losing API access on 12 January 2027, and remaining public API access ends in March 2027. Existing approved apps still work for now; new public API access is effectively closed. Verify the current state in your Reddit account before building a production rollout.

Overview

PropertyValue
Identifierreddit
Display nameReddit
Connection modeoauth (a human opens a Reddit consent page)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are flat top-level comments on the submission, not nested reply chains
Pending / async publishingYes. supports_pending = true; post_pending records the subreddit list, finalize_post publishes exactly one subreddit per call
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job1 — Reddit’s write limits are strict and shared per OAuth client
Character limit (max_length)10000 characters, hard-coded
OAuth scopesread, identity, submit, flair
Redirect URI{FRONTEND_URL}/integrations/social/reddit
API hosthttps://oauth.reddit.com (override REDDIT_BASE_URL)
OAuth / token hosthttps://www.reddit.com (override REDDIT_WEB_BASE_URL)

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
REDDIT_CLIENT_IDYes—Client id shown under the app name at reddit.com/prefs/apps
REDDIT_CLIENT_SECRETYes—App secret shown on the app card
REDDIT_BASE_URLNohttps://oauth.reddit.comAPI origin for /api/v1/me, /api/submit, subreddit lookups, media assets, comments. A trailing slash is stripped.
REDDIT_WEB_BASE_URLNohttps://www.reddit.comOrigin used for /api/v1/authorize and /api/v1/access_token, and to build release URLs. A trailing slash is stripped.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the OAuth redirect URI. Trailing slashes are stripped.
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state row.

rustiz providers --status only checks that REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET are non-empty. It cannot tell whether the app was approved for the Data API, whether the redirect URI matches, or whether the credentials belong to the intended Reddit account.

The two base-URL overrides are independent: setting only REDDIT_BASE_URL does not redirect the OAuth authorize or token calls. For a mock server, set both.

Step-by-Step Developer App Setup

1. Create the Reddit app

  1. Sign in to Reddit with the account that will own the app.
  2. Open https://www.reddit.com/prefs/apps (or https://old.reddit.com/prefs/apps).
  3. Scroll to Developed Applications and click create another app / are you a developer? create an app.
  4. Fill in:
    • name — e.g. Rustiz or MyRustizInstance.
    • type — web app. This is the confidential type that has a client secret and supports the authorization-code flow. Do not use installed app (no secret) or script (only the owner’s account).
    • description — anything descriptive.
    • about url — your deployment URL (optional but recommended).
    • redirect uri — the exact Rustiz callback from the next section.
  5. Click create app and copy:
    • the client id shown immediately under the app name;
    • the secret shown on the app card.

Reddit’s app page and the approval process are separate. Creating the app does not grant Data API access by itself. If Reddit’s portal refuses to create a new app or the approval request is closed for your use case, this provider cannot be used; there is no code-side workaround.

2. Set the environment and restart

REDDIT_CLIENT_ID="…"
REDDIT_CLIENT_SECRET="…"
FRONTEND_URL="https://social.example.com"

Restart every process that reads configuration (rustiz serve, rustiz worker, rustiz mcp), then check:

rustiz providers --status

3. Redirect URI (exact)

Reddit::redirect_uri() is:

#![allow(unused)]
fn main() {
format!(
    "{}/integrations/social/reddit",
    self.ctx.config.frontend_url()
)
}

Config::frontend_url() trims trailing slashes, so the registered value must be without a trailing slash:

https://social.example.com/integrations/social/reddit

Local examples:

http://localhost:5000/integrations/social/reddit   # Docker / rustiz serve default
http://localhost:4200/integrations/social/reddit   # upstream Postiz dev UI

There is no redirectmeto.com wrapper for Reddit. Reddit requires the redirect_uri to match the registered URI exactly at both the authorize step and the token exchange; a mismatch produces a 403 on the consent page or invalid_grant at token exchange.

4. Scopes requested and checked

Reddit::scopes() returns:

#![allow(unused)]
fn main() {
vec!["read", "identity", "submit", "flair"]
}

The authorize URL joins them with %20:

https://www.reddit.com/api/v1/authorize?client_id=…
  &response_type=code
  &state=…
  &redirect_uri=…
  &duration=permanent
  &scope=read%20identity%20submit%20flair
ScopeWhy Rustiz needs it
read/subreddits/search, /r/{name}/about, /user/{name}/submitted, post requirements and flair listings
identityGET /api/v1/me during connect (id, name, avatar)
submitPOST /api/submit and POST /api/comment
flairReading link_flair_v2 and sending flair_id

authenticate() calls check_scopes() on the token response’s scope string. The helper accepts comma- or space-separated scopes. If the user unchecks any of the four scopes, the connection fails with NotEnoughScopes rather than silently connecting a partially working channel.

duration=permanent is always requested, so Reddit returns a refresh token. Access tokens expire after one hour; the refresh token does not.

5. Sandbox / testing vs production approval

Reddit has no sandbox app type or test-mode client. There is one app and one approval state.

  1. Request Data API access first. Submit Reddit’s developer request form (the link is on the Responsible Builder Policy page) and describe the app, the subreddits it will touch, the actions it needs, and how you handle deleted content. Reddit’s review is human and typically takes weeks; it can be refused.
  2. Test with a test account and a subreddit you moderate. Create a private or low-traffic test subreddit (for example r/u_<yourname> or a private r/rustiz_test) so a bad post cannot hit a real community. Reddit’s site-wide rules and each subreddit’s own rules still apply.
  3. Expect account-age and karma gates. Many subreddits reject posts from new or low-karma accounts even when the API call succeeds. That is subreddit moderation, not an OAuth failure.
  4. A successful OAuth consent is not API approval. Without approved API access, the consent page may work while oauth.reddit.com returns 401, 403 or throttled traffic.
  5. Production uses the same client id/secret, redirect URI and scopes. There is no per-environment app review. Commercial use needs a separate written agreement with Reddit.
  6. Register the app on the Developer Platform. Reddit’s migration guidance asks existing apps to register at https://developers.reddit.com/app-registration and engage with the migration team. Apps that do not register or respond start losing API access on 12 January 2027.

2026 migration: Reddit’s public Data API is being replaced by apps that run on the Developer Platform (Devvit). Reddit’s September 2026 migration post says unregistered apps start losing access on 12 January 2027 and remaining public API access ends in March 2027. Plan a Devvit migration or a contractual arrangement if Reddit is business-critical.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect reddit --open
  • The login URL is printed to stderr; --open launches the default browser.
  • With a local FRONTEND_URL (http://localhost:… / http://127.0.0.1:…) and no separate server, the CLI serves the callback itself on the frontend port and waits up to --timeout seconds (default 300) for the channel to appear.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.
  • On success the JSON document contains connected: true and the new channel’s id, identifier, name, profile, picture and refreshNeeded fields.

MCP connectChannelTool

Start:

{ "provider": "reddit" }

The tool returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://www.reddit.com/api/v1/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Give the url to a human, then poll:

{ "connectionId": "…" }

provider is not needed when polling. Polling is safe to repeat; the pending state lives in oauth_states for one hour and is scoped to the organization.

Follow-up functions after connecting

Reddit has no customFields, no externalUrl, and is_between_steps = false, so there is no channelSetupTool step. The subreddit list and flair are chosen when the post is created, not when the channel is connected.

Two read-only @Tool functions are exposed through triggerTool:

{
  "integrationId": "<reddit channel id>",
  "methodName": "subreddits",
  "dataSchema": [{ "key": "word", "value": "rust" }]
}
{
  "integrationId": "<reddit channel id>",
  "methodName": "restrictions",
  "dataSchema": [{ "key": "subreddit", "value": "/r/rust" }]
}

subreddits accepts a search word or a path such as /r/rust; it returns {title, name, id} entries for public subreddits, excluding image-only subreddits. restrictions returns:

{
  "subreddit": "/r/rust",
  "allow": ["self", "link", "media"],
  "is_flair_required": true,
  "flairs": [{ "id": "…", "name": "Discussion" }]
}

Use those values to build settings.subreddit[*].value; they are not stored on the integration.

What not to send

  • customFields — the generic connect path would base64-encode the object and pass it as the OAuth code, which Reddit will reject. Reddit is OAuth only.
  • externalUrl — Reddit is not self-hosted. The generic path calls the provider’s external_url(), which returns Unsupported("externalUrl").

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "subreddit": {
      "type": "array",
      "minItems": 1,
      "description": "One entry per subreddit the post is published to.",
      "items": {
        "type": "object",
        "properties": {
          "value": {
            "type": "object",
            "properties": {
              "subreddit": {
                "type": "string",
                "minLength": 2,
                "description": "Subreddit must start with /r"
              },
              "title": {
                "type": "string",
                "minLength": 2,
                "description": "Title of the Reddit submission."
              },
              "type": {
                "type": "string",
                "enum": ["self", "link", "media"],
                "description": "self = text post, link = URL post, media = first attached image or MP4 video"
              },
              "url": {
                "type": "string",
                "description": "Destination URL when type is link."
              },
              "is_flair_required": { "type": "boolean" },
              "flair": {
                "type": "object",
                "properties": {
                  "id": { "type": "string" },
                  "name": { "type": "string" }
                },
                "required": ["id", "name"]
              }
            },
            "required": ["subreddit", "title", "type"]
          }
        },
        "required": ["value"]
      }
    }
  },
  "required": ["subreddit"],
  "additionalProperties": true
}

Each array entry is one subreddit. The same text and the first media item are reused for every entry; there is no per-subreddit body/title override.

validate_settings() rules

  • subreddit must be an array with at least one entry.
  • Every entry must have a value object.
  • value.subreddit must be at least 2 characters. The error says “Subreddit must start with /r”, but the code only checks length. rust and /r/rust both pass.
  • value.title must be at least 2 characters.
  • value.type must be one of self, link, media.
  • For type: "link", if url is present it must parse as http/https, or contain the literal (post:. A missing url key is not rejected (the check is skipped); an empty string is rejected as Invalid URL.
  • If is_flair_required is truthy, flair.id and flair.name must both be non-empty.
  • If is_flair_required is false, an attached flair is still sent if present.

check_validity() rules

Two pre-flight errors are returned as strings:

  1. If any subreddit entry has type: "media" and the first post group does not contain exactly one media item: When posting a media post, you must attached exactly one media file. The check uses posts.first() only, so it is really “exactly one media in the main post”, not per subreddit.
  2. If any post in any group has an .mp4 path without a thumbnail: You must attach a thumbnail to your video post. The check is case-sensitive (mp4), while the upload path lowercases the extension.

Media requirements

TypeAccepted by the providerNotes
type: "self"No media neededText post; media attached to the engine post is ignored
type: "link"No media neededurl is sent as the link destination
type: "media", imageExactly 1 imageUploaded to Reddit’s S3 asset endpoint, then submitted as kind=image
type: "media", videoExactly 1 .mp4Submitted as kind=video; a thumbnail is mandatory and uploaded as video_poster_url

The upload flow is:

  1. POST {REDDIT_BASE_URL}/api/media/asset with multipart fields filepath and mimetype.
  2. Reddit returns args.action (often protocol-relative, //…) and args.fields.
  3. Rustiz re-uploads the file as multipart to the S3 action and reads the <Location> element from the XML response.
  4. The returned URL is sent as url in /api/submit.

MIME type is chosen from the path extension; a query string makes the extension unknown, so the part falls back to application/octet-stream. Reddit’s own size limits still apply (commonly 20 MB for images and 1 GB for video); Rustiz does not pre-check size.

Non-MP4 video (.mov, .webm) is treated as an image because the only video test is path.ends_with(".mp4") during finalize_post (has_extension is case-insensitive there). Use MP4 for native video.

Threads and comments

supports_comments = true. The first entry is the submission; each later entry is published with POST /api/comment and thing_id = t3_<root post id>.

last_comment_id is accepted but ignored. Every comment is a top-level reply on the submission, so a multi-entry post is a flat comment list, not a nested thread.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Pending state machine. post_pending() stores the full subreddit list, the message, the first media path, its thumbnail, a cursor and the results in pending_data. It does not call Reddit. check_post_status() arms the next subreddit, and finalize_post() publishes one subreddit per call and advances the cursor. This is deliberate: a crashed activity cannot replay the whole loop and duplicate already-published subreddits.
  • Armed marker before submit. check_post_status() writes armed: {sr, title, armedAt, media, submitted, lookups} before the workflow commits the state and calls finalize_post(). If finalize dies mid-submit, the next check sees the marker and looks up the user’s listing instead of blindly resubmitting.
  • Recovery lookup. find_submitted_post() calls GET /user/{username}/submitted?limit=25&sort=new on the API host, then matches subreddit, exact title and created_utc > armed_at - 60s. Only 25 newest submissions are inspected. Legacy rows without integration.profile cannot recover and fall through to the grace budget.
  • Grace budgets. A media submit that was accepted but not yet visible keeps polling for up to 9 lookups (engine checks are about 20 seconds apart). An unaccepted media submit is not resubmitted until both 10 minutes have elapsed and 9 lookups have run. A self/link submit gets one grace lookup. When the budget expires, media records an empty post id and the subreddit URL; self/link disarms and re-arms so it can submit again.
  • RATELIMIT is not a failure. Reddit returns HTTP 200 with json.errors[0][0] == "RATELIMIT". finalize_post() treats an all-RATELIMIT response as “nothing was submitted”, disarms and returns pending; the next check re-arms later. Any other Reddit error becomes a BadBody with Reddit rejected the post to r/<sr>: <messages>.
  • Blocking compatibility path. post() runs post_pending → check → finalize in a loop with 5-second sleeps and an 8-minute timeout. The engine uses the pending path because supports_pending = true; the blocking path is kept for pre-v1.0.6 workflows.
  • Malformed settings can complete empty. post_pending() deserializes settings.subreddit with unwrap_or_default(). A caller that bypasses validate_settings() and sends a non-array value gets an empty subreddit list; check_post_status() then sees cursor >= len and reports Completed with empty postId/releaseURL. Always validate settings before creating a post.
  • Multi-subreddit result shape. completed_response() joins post_ids and releaseURLs with commas. A media post whose lookup budget expired contributes an empty post_id but a valid subreddit URL. Consumers must split on commas, not assume one id.
  • Rate limits are per OAuth client id, not per user. Reddit’s free Data API limit is 100 queries per minute per client id, averaged over 10 minutes. Every Reddit channel on one Rustiz deployment shares that budget. The provider sets max_concurrent_job = 1 to keep bursts under control, but a single media post costs several requests (asset, S3 upload, submit, possibly listing lookups).
  • User-Agent is generic. The shared HTTP client sends rustiz/<crate version> and the Reddit provider does not override it. Reddit’s policy asks for a unique, descriptive User-Agent containing the platform, app id, version and a Reddit username contact. A generic agent can be aggressively throttled. If Reddit is a production channel, add a deployment-specific User-Agent (and keep it honest).
  • No PKCE. generate_auth_url() creates a code_verifier for the shared response shape but never sends it. Reddit’s web-app flow uses the client secret, so this is harmless, but the field is unused.
  • Token refresh is simple. refresh_token() posts grant_type=refresh_token with the same Basic auth, calls /api/v1/me again, and keeps the same refresh token. It does not call check_scopes(); a token whose scopes were reduced is only discovered when a missing endpoint returns an error.
  • Comment thread is flat. last_comment_id is ignored. If a product requirement is “reply to the previous comment”, this provider does not do it.
  • Comment permalink can collapse. release_url is {REDDIT_WEB_BASE_URL}{permalink}. If Reddit omits permalink, the URL becomes the bare web origin.
  • restrictions path bug. It derives the subreddit name with subreddit.split_once("/r/") and falls back to the literal "undefined". Passing r/rust (no leading slash) therefore calls /api/v1/undefined/post_requirements. Always pass /r/<name>.
  • subreddits hides image-only communities. Search results with submission_type == "image" and non-public subreddits are filtered out, so a valid image-only community may not appear in the picker. Direct /r/<name> lookup applies the same filter.
  • type: "link" URL is not required by validation. A settings object with type: "link" and no url passes validate_settings() and fails only at Reddit with an error. Validate it upstream or in your agent prompt.
  • Title length is not checked. Reddit caps titles at 300 characters (subreddits can set a lower cap); Rustiz only enforces a 2-character minimum. The provider also caps the whole message at 10,000 characters, below Reddit’s 40,000-character text-post limit, so long-form text is truncated/rejected locally before Reddit sees it.
  • Flair requirement is best-effort. restrictions reports is_flair_required as true only when Reddit says flair is required and the flair list is non-empty. If the flair endpoint fails, Rustiz returns an empty list and treats flair as optional; the submit may then be removed by the subreddit’s AutoModerator.
  • Media upload is unauthenticated at the S3 step. The second request goes to the URL Reddit returns, not to oauth.reddit.com, and uses the shared HTTP client. A local mock server must return an absolute action; the code accepts absolute URLs for testability and prefixes protocol-relative URLs with https:.
  • .mp4 check is split. check_validity() checks lowercase mp4; finalize_post() uses case-insensitive has_extension. An uppercase .MP4 passes pre-flight but then requires a thumbnail and takes the video branch.
  • Reddit 401 handling. Classify is a no-op, so the shared HTTP layer maps a 401 to RefreshToken and the engine refreshes once. A second failure flags the channel for reconnection. Reddit’s 403 (no API approval, banned subreddit, missing scope) is a BadBody, not a refresh.
  • API sunset. The provider still speaks the classic OAuth + Data API. It does not implement Devvit, Reddit’s new app platform. Expect this page to become migration guidance rather than setup guidance as 2027 approaches.

References

Slack (slack)

Slack is a workspace chat platform. Rustiz installs a Slack app with a bot token, lists public and private channels, posts messages with Block Kit, and threads comments under the original message. Media is sent as Block Kit image blocks, so files must be publicly reachable URLs; videos are rejected before publishing.

  • Rust source: crates/rustiz-providers/src/slack.rs
  • Upstream port: slack.provider.ts
  • Editor: Normal
  • Connection mode: oauth

Overview

PropertyValue
Identifierslack
Display nameSlack
Connection modeoauth (a human installs the app into a workspace)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are replies with thread_ts
Pending / async publishingNo. supports_pending = false; one chat.postMessage per post
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job10 — Slack limits are per workspace, but posting is 1 msg/sec/channel
Character limit (max_length)400000 characters (this is not Slack’s real limit; see below)
OAuth scopeschannels:read, chat:write, users:read, groups:read, channels:join, chat:write.customize
Redirect URI{FRONTEND_URL}/integrations/social/slack, wrapped in https://redirectmeto.com/ when FRONTEND_URL does not contain https
API hosthttps://slack.com (override SLACK_BASE_URL)

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["SLACK_ID", "SLACK_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
SLACK_IDYes—Slack Client ID (not SLACK_CLIENT_ID)
SLACK_SECRETYes—Slack Client Secret (not SLACK_CLIENT_SECRET)
SLACK_BASE_URLNohttps://slack.comOrigin for OAuth and /api/... calls. Trailing slash stripped.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI; also decides whether the redirectmeto.com tunnel is used
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state row

The variable names are a common trap: the Slack provider uses SLACK_ID / SLACK_SECRET, while most other OAuth providers use *_CLIENT_ID / *_CLIENT_SECRET.

rustiz providers --status only checks the two credential variables. It cannot tell whether the app has the six scopes, whether the redirect URL is registered, or whether the app is allowed to install into the target workspace.

For GovSlack, set SLACK_BASE_URL=https://slack-gov.com; the provider appends /oauth/v2/authorize and /api/... itself.

Step-by-Step Developer App Setup

1. Create the Slack app

  1. Open https://api.slack.com/apps and click Create New App.
  2. Choose From scratch.
  3. Name it (e.g. Rustiz) and select the development workspace where you will test. A new app can only be installed in this workspace until public distribution is activated.
  4. Open Basic Information → App Credentials and copy the Client ID and Client Secret.

2. Add the redirect URL

Open Features → OAuth & Permissions → Redirect URLs and add the exact URL Rustiz will send.

Slack::redirect_uri() is:

#![allow(unused)]
fn main() {
fn redirect_uri(&self, refresh: Option<&str>) -> String {
    let frontend = self.ctx.config.frontend_url();
    let prefix = if frontend.contains("https") {
        ""
    } else {
        "https://redirectmeto.com/"
    };
    let refresh = refresh.map(|r| format!("?refresh={r}")).unwrap_or_default();
    format!("{prefix}{frontend}/integrations/social/slack{refresh}")
}
}

The check is a substring test, not a URL scheme parse. Any FRONTEND_URL containing the letters https is used directly; everything else is tunnelled through https://redirectmeto.com/.

Production example:

https://social.example.com/integrations/social/slack

Docker / local example (FRONTEND_URL=http://localhost:5000):

https://redirectmeto.com/http://localhost:5000/integrations/social/slack

Register the tunnelled URL in Slack for local HTTP deployments, not the plain http://localhost:5000/... URL. Slack requires HTTPS redirect URLs and rejects plain http production URLs.

Slack matches a redirect URI if it is equal to or a subdirectory of a registered Redirect URL. It also requires the same redirect_uri value in the authorize and token-exchange steps.

Reconnect quirk: when a connection is started with a refresh query parameter, generate_auth_url() uses the redirect URI without ?refresh=<id> (it calls redirect_uri(None)), while authenticate() calls redirect_uri(params.refresh.as_deref()) and therefore sends .../slack?refresh=<id> at token exchange. Slack’s own rule says the two values must match. If Slack returns bad_redirect_uri during a reconnect, start a fresh connection without the refresh parameter instead of retrying the refresh flow.

3. Add the bot token scopes

In OAuth & Permissions → Scopes → Bot Token Scopes, add all six scopes:

ScopeWhy Rustiz needs it
channels:readconversations.list for public channels
groups:readconversations.list for private channels
chat:writechat.postMessage
users:readusers.info to resolve the bot user’s name/avatar during connect
channels:joinconversations.join before posting to a public channel
chat:write.customizeusername and icon_url overrides; Rustiz always sends username

The Postiz documentation lists only five scopes and omits chat:write.customize. That page is stale for this Rust port. check_scopes() requires all six at connect time, so a Slack app built from the five-scope list fails the connection with NotEnoughScopes before any post is made.

Slack scopes are additive for an existing installation: adding a scope and reinstalling grants the new scope without removing the old ones. Removing a scope requires revoking the token and reinstalling.

4. Set an app icon

In Settings → Basic Information → Display Information, upload an app icon. Postiz’s docs require a square image between 512×512 and 2000×2000. Rustiz uses the bot user’s profile.image_original as the integration picture and as icon_url on every message; a missing icon produces an empty/placeholder avatar and may make the UI reject the integration in the upstream Postiz frontend.

5. Install the app and set the environment

Install the app into the workspace from OAuth & Permissions → Install to Workspace. Rustiz performs the OAuth install itself, but installing once in the portal is a quick way to verify the scopes.

SLACK_ID="…"
SLACK_SECRET="…"
FRONTEND_URL="https://social.example.com"

Restart rustiz serve, rustiz worker and rustiz mcp, then:

rustiz providers --status

6. Scopes requested and checked

The authorize URL is:

https://slack.com/oauth/v2/authorize
  ?client_id=<SLACK_ID>
  &redirect_uri=<urlencoded redirect>
  &scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize
  &state=<state>

Slack accepts a comma-separated scope list. The token exchange posts client_id, client_secret, code and redirect_uri to https://slack.com/api/oauth.v2.access. Slack returns scope as a comma-separated string; check_scopes() accepts commas or spaces.

authenticate() then calls users.info?user=<bot_user_id> with the bot token to get real_name, name and profile.image_original. The integration id is the team id, the name is the bot’s real name, and the username is the bot’s Slack username.

7. Sandbox / testing vs production review

Slack has no separate sandbox app or test-mode client. A newly created app can be installed only in its development workspace; that is the testing environment.

  • Single workspace / internal use: install and use immediately. No Slack review.
  • Other workspaces: open Manage Distribution, complete the checklist, and click Activate Public Distribution. This is a configuration toggle, not a review; it takes effect immediately. Without it, installing in another workspace fails with invalid_team_for_non_distributed_app.
  • Slack Marketplace (App Directory) listing: optional and separately reviewed. Current Slack requirements include a fully tested app, at least 10 active workspaces, a demo video and scope justification; the preliminary review is about 10 business days and functional review can take up to 10 weeks for a new submission.
  • HTTPS is mandatory for distributed apps. All OAuth redirect URLs, interaction request URLs, Events API URLs and Block Kit option-load URLs must be HTTPS. The redirectmeto.com prefix exists to satisfy this during local development.
  • Token rotation: do not enable Slack token rotation for this integration. Rustiz stores the bot token as non-expiring (expires_in of 100 years) and its refresh_token() implementation is a no-op that returns an empty access token. If rotation is enabled, the token eventually expires and Rustiz cannot refresh it; the channel is flagged for reconnection instead.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect slack --open
  • The login URL is printed to stderr; --open opens the browser.
  • With a local FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to --timeout seconds (default 300) for the channel to appear.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.

For a local HTTP deployment, the browser goes to https://redirectmeto.com/http://localhost:5000/integrations/social/slack?..., which then redirects to the local callback. The CLI still serves the local callback on the frontend port.

MCP connectChannelTool

Start:

{ "provider": "slack" }

The tool returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://slack.com/oauth/v2/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Give the url to a workspace member who can install apps, then poll:

{ "connectionId": "…" }

Choosing the channel after connecting

is_between_steps = false; there is no channelSetupTool step. Use triggerTool with the channels function:

{
  "integrationId": "<slack channel id>",
  "methodName": "channels",
  "dataSchema": []
}

The result is [{ "id": "C012AB3CD", "name": "general" }, …]. Only the first page of conversations.list is fetched (no limit or cursor parameters), so a large workspace may not show every channel. Archived channels are included because the request does not set exclude_archived=true.

Store the chosen id in the post settings:

{ "channel": "C012AB3CD" }

channelSetupTool is not used by Slack.

What not to send

  • customFields — Slack is OAuth only; the generic path would base64-encode the object and pass it as an OAuth code.
  • externalUrl — Slack is not self-hosted. The generic path calls the provider’s external_url(), which returns Unsupported("externalUrl").

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "channel": {
      "type": "string",
      "minLength": 1,
      "description": "Id of the Slack channel to post to"
    }
  },
  "required": ["channel"],
  "additionalProperties": true
}

channel must be a channel id (C…) or private-group id (G…), not a #channel-name. DMs are not supported by the channel picker because conversations.list is called with only public_channel,private_channel.

validate_settings() rules

  • channel must exist and be a string.
  • It must not be empty.
  • There is no check that the channel exists, that the bot is a member, or that the id belongs to the connected workspace. A bad id fails at Slack with channel_not_found / not_in_channel.

check_validity() rules

One pre-flight check:

#![allow(unused)]
fn main() {
if posts.iter().any(|post| post.iter().any(|item| item.path.contains("mp4"))) {
    return Ok(Some("No video support for Slack, only images".into()));
}
}
  • Only lowercase mp4 is caught. A .MOV or .WebM file passes validation and is then sent as a Block Kit image block, where Slack cannot render it.
  • There is no check for the number of images/blocks, public URL reachability, file type (PNG/JPG/JPEG/GIF only), or message length.

Media requirements

publish() builds:

{
  "channel": "C012AB3CD",
  "username": "<integration.name>",
  "icon_url": "<integration.picture>",
  "blocks": [
    { "type": "section", "text": { "type": "mrkdwn", "text": "<message>" } },
    { "type": "image", "image_url": "<media path>", "alt_text": "" },
    ...
  ],
  "thread_ts": "<only for comments>"
}

and calls POST /api/chat.postMessage with a Bearer bot token.

RequirementDetail
Image hostimage_url must be publicly reachable by Slack’s servers. A local upload path or a URL behind authentication renders as a broken image.
Image formatsSlack supports PNG, JPG/JPEG and GIF. WebP, SVG, BMP and TIFF are not supported by image blocks.
URL lengthimage_url maximum is 3000 characters.
alt_textRequired by Slack; Rustiz sends an empty string. It is accepted but poor for accessibility.
Blocks per messageMaximum 50 blocks. Rustiz emits one section block plus one image block per media item, so more than 49 images exceeds the block limit.
VideoNot supported. The early check only catches lowercase .mp4; other video containers slip through.
TextThe entire message is put in a single section block, whose text maximum is 3000 characters.

The provider never sends a top-level text fallback alongside blocks, so screen readers and notification previews rely on the section block. Slack’s accessibility guidance recommends a top-level fallback; this port does not provide one.

Threads and comments

supports_comments = true.

  • comment() computes thread_ts = last_comment_id.unwrap_or(post_id).
  • For the first reply, last_comment_id is None, so post_id (the original message ts) becomes the thread parent.
  • For later replies, last_comment_id is the previous comment’s post_id, which is also a valid Slack message ts. Slack accepts any message in the thread as thread_ts, so a multi-entry post forms a normal Slack thread.
  • Each reply sends the same Block Kit structure and its own media.
  • releaseURL comes from chat.getPermalink for each message.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • max_length is misleading. It returns 400000, but the message is always sent inside a Block Kit section block, whose text limit is 3000 characters. A 3500-character post passes Rustiz validation and is rejected by Slack with invalid_blocks / msg_blocks_too_long. If you need longer content, use a snippet/file upload, which this provider does not implement.
  • chat:write.customize is required, despite the docs. The publish payload always includes username (integration.name) and, when present, icon_url. Slack only permits those overrides with chat:write.customize. Rustiz checks the scope at connect, so an app built from Postiz’s five-scope list cannot connect at all.
  • Redirect tunnel is a substring check. frontend.contains("https") is not a scheme check. A URL like http://my-https-proxy.local is treated as HTTPS and sent directly to Slack, which then rejects it because the scheme is http. Keep FRONTEND_URL either a real https:// URL or a plain http://localhost:… URL.
  • Local redirect URL. For FRONTEND_URL=http://localhost:5000, register https://redirectmeto.com/http://localhost:5000/integrations/social/slack in Slack. Registering the plain http://localhost:5000/... URL does not match the URI Rustiz sends.
  • Reconnect ?refresh= mismatch. As described above, the authorize step omits ?refresh, the token exchange adds it. Slack requires the same value in both steps. If reconnecting an existing Slack channel fails with bad_redirect_uri, start a new connection instead of using the refresh flow.
  • conversations.join failure is ignored. post() calls POST /api/conversations.join and discards the response, then posts. For a public channel where the bot is not a member, channels:join usually fixes it. For a private channel, the bot must be manually invited; channels:join cannot join private channels. The subsequent chat.postMessage then fails with not_in_channel.
  • Permalink failure after a successful post can duplicate. publish() sends the message, then calls chat.getPermalink. If that second call fails (for example a transient 5xx after retries), the engine sees a failed post and may retry the whole publish, creating a duplicate message. There is no idempotency key. Check the channel before retrying a failed Slack post.
  • Channel list is one page. channels() calls conversations.list?types=public_channel,private_channel with no limit and no cursor. Slack’s default limit is 100; larger workspaces only see the first page. Archived channels are included (exclude_archived defaults to false), and posting to an archived channel fails.
  • Only text and images. chat.postMessage accepts files, but this provider only emits image blocks. PDFs, videos, audio and arbitrary documents are not supported.
  • Image URLs must be public. Rustiz does not upload media to Slack. If your media store requires signed URLs, they must remain valid long enough for Slack’s image proxy to fetch them; a short-lived URL can render as a broken image even though the message posts successfully.
  • alt_text is empty. Slack requires the field but Rustiz sets "". The message posts, but accessibility and some clients’ fallback rendering suffer.
  • Token model. Slack bot tokens do not expire by default. authenticate() stores refresh_token: Some("null") (the literal string) and expires_in: Some(100 * 365 * 24 * 3600). refresh_token() returns an empty access token and expires_in: 1_000_000; it never calls Slack. A revoked token cannot be repaired automatically: the channel is flagged for reconnection.
  • Token rotation is incompatible. Enabling Slack token rotation makes the bot token expire. Rustiz’s refresh is a no-op, so the channel breaks. Leave rotation off, or implement a real refresh before enabling it.
  • Error handling is ok:false aware. Slack returns HTTP 200 with {"ok":false,"error":"…"} for most failures. check_api_error() maps invalid_auth, token_revoked, token_expired and account_inactive to a refresh error; ratelimited becomes a plain error; everything else becomes BadBody. A 200 response is not retried by the shared HTTP layer, so a ratelimited body on a 200 is not retried.
  • Rate limits. Slack’s chat.postMessage allows about 1 message per second per channel, with a workspace-wide limit of several hundred messages per minute. max_concurrent_job = 10 can put multiple posts into the same channel at once; the 1/sec channel bucket still applies. Slack returns HTTP 429 with Retry-After, which the shared HTTP layer retries.
  • Enterprise Grid org-wide installs are not handled. The code reads the workspace id from token["team"]["id"] only. Slack returns team: null for an org-level installation (is_enterprise_install: true) and puts the id in the enterprise object. Rustiz does not read enterprise.id, so the integration’s internal_id can be empty. Install the app per workspace unless you have tested the org-wide flow.
  • change_nickname() and change_profile_picture() are no-ops. They return the input string without calling Slack. A Slack bot’s display name and icon are managed in the Slack app settings, not per integration.
  • No analytics, mentions or plugs. Slack has no analytics methods, no post analytics, supports_mentions = false, and no plug hooks in this port.
  • SLACK_BASE_URL affects release URLs. chat.getPermalink returns a Slack-hosted URL, so release URLs stay correct even with a mock base; message IDs and channel ids are real Slack values.

References

Telegram (telegram)

Telegram is a bot platform, not an OAuth provider. Rustiz runs one bot for the whole deployment, identified by TELEGRAM_TOKEN; each connected channel is simply a chat id that the bot has already joined. The connect flow asks a human to send /connect <word> to the bot in the target group or channel, then polls Telegram’s getUpdates endpoint until the message appears.

  • Rust source: crates/rustiz-providers/src/telegram.rs
  • Connect flow: crates/rustiz-server/src/connect.rs
  • Upstream port: telegram.provider.ts
  • Editor: HTML
  • Connection mode: telegramBot

Overview

PropertyValue
Identifiertelegram
Display nameTelegram
Connection modetelegramBot (human sends a bot command; no browser OAuth)
Editorhtml
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are replies with reply_to_message_id
Pending / async publishingNo. supports_pending = false; one Bot API call (or media group sequence) per post
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3 — conservative against Bot API limits
Character limit (max_length)4096 characters for text; media captions are limited to 1024
API hosthttps://api.telegram.org (override TELEGRAM_BASE_URL)
OAuth scopesNone
Redirect URINone

The provider sets info.is_web3 = true because the upstream Postiz class does. That flag is not used for Telegram’s connect path: connect::start() has an explicit "telegram" branch before the is_web3 rejection, and connect::describe() returns type: "telegramBot". It does not mean Telegram is a crypto/web3 platform.

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["TELEGRAM_TOKEN"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
TELEGRAM_TOKENYes—Bot API token from BotFather, format <bot_id>:<secret>
TELEGRAM_BOT_NAMENounsetBot username without @, used only in the connect instructions (@name). Not used for API calls.
TELEGRAM_BASE_URLNohttps://api.telegram.orgOrigin for all Bot API calls. Trailing slash stripped. Useful for a local Bot API server.
STORAGE_PROVIDERNolocalWhen local, media URLs beginning with FRONTEND_URL are converted to local upload paths before sending.
FRONTEND_URLYes in practicehttp://localhost:5000Used to recognise local media URLs and in the generic OAuth state machinery (not for Telegram’s redirect).
JWT_SECRETYes in practiceemptyEncrypts the pending connect state row.

TELEGRAM_BOT_TOKEN is not read; the exact name is TELEGRAM_TOKEN. rustiz providers --status checks only that TELEGRAM_TOKEN is non-empty. It cannot tell whether the token is valid, whether a webhook is configured, or whether another process is polling the same bot.

TELEGRAM_BOT_NAME only changes the text of the connect message. If it is unset, the message says “the Telegram bot”; the actual /connect <word> flow still works. The username in the message has no @ in the environment value; the code adds it.

Step-by-Step Developer App Setup

1. Create the bot with BotFather

  1. Open Telegram and start a chat with @BotFather.
  2. Send /newbot.
  3. Choose a display name and a username ending in bot (e.g. MyRustizBot / MyRustizBot_bot).
  4. BotFather returns an API token such as 123456789:AAH…. This is TELEGRAM_TOKEN. Treat it like a password.
  5. Optionally send /setdescription, /setabouttext, /setuserpic and /setcommands to make the bot presentable.

There is no developer portal, no app review and no OAuth client.

2. Decide the bot’s privacy mode

For groups, the bot must see the /connect <word> message. By default BotFather enables privacy mode, which limits which group messages a bot receives. Rustiz matches the update text exactly:

/connect <word>

It does not match /connect@MyRustizBot <word>. If privacy mode is on in a group, the plain command may not be delivered, and the connection times out.

Disable privacy mode:

  1. Chat with @BotFather.
  2. Send /mybots, select the bot.
  3. Bot Settings → Group Privacy → Turn off (“Privacy mode is disabled”).
  4. Remove and re-add the bot to the group if it was already a member, so the new setting takes effect.

For channels, the bot receives channel posts when it is a member; making it an administrator is strongly recommended so it can post and clean up the connect messages.

3. Add the bot to the target chat

  • Group: add the bot as a member. To post media and delete its own messages, grant the relevant permissions or make it an administrator.
  • Channel: add the bot as an administrator with at least:
    • Post messages (can_post_messages)
    • Edit messages (optional)
    • Delete messages (can_delete_messages) — used to remove the /connect command and the bot’s “Connection Successful” notice
    • media sending permissions

Without administrator rights in a channel, the bot can often read updates but cannot publish. The connect flow itself can still succeed in a group without admin rights; Rustiz then tells the human to delete the connect messages manually.

4. Remove any webhook

getUpdates and setWebhook are mutually exclusive. If the bot was previously used with a webhook, delete it:

curl -sS "https://api.telegram.org/bot${TELEGRAM_TOKEN}/deleteWebhook"

If a webhook remains, getUpdates fails with 409 Conflict: can't use getUpdates while webhook is active.

5. Set the environment and restart

TELEGRAM_TOKEN="123456789:AAH…"
TELEGRAM_BOT_NAME="MyRustizBot_bot"

Restart rustiz serve, rustiz worker and rustiz mcp, then:

rustiz providers --status

6. Only one poller per bot token

Telegram allows one getUpdates consumer per bot token. If the same token is used by two Rustiz deployments, a staging and a production instance, or a second bot process, each instance continuously evicts the other and the logs fill with:

409 Conflict: terminated by other getUpdates request

Use a separate bot per deployment. This is the most common Telegram misconfiguration.

7. Sandbox / testing vs production

Telegram has no sandbox, no test-mode bot and no app review. A bot is free and immediately live.

  • Create a private test group or channel and a dedicated bot for testing.
  • Test /connect, text posts, one-image posts, multi-image albums, video with a thumbnail, and comments.
  • There is no “production app” switch; the same bot token works everywhere. Restrict who can add the bot to chats via BotFather’s group settings if needed.
  • Telegram’s Bot Developer Terms apply. Bots must not spam, must respect Telegram’s automation rules and must not scrape private data.

Connecting a Channel (CLI & MCP)

CLI

Start the two-step flow:

rustiz channels connect telegram

The JSON document contains:

{
  "connected": false,
  "pending": true,
  "connectionId": "tg_…",
  "word": "Ab3xY9zQ1p",
  "command": "/connect Ab3xY9zQ1p",
  "message": "Add @MyRustizBot to the Telegram group or channel (as an administrator), then send `/connect Ab3xY9zQ1p` in that chat. Call connectChannelTool or GET /public/v1/integrations/connect/{connectionId} again with this connectionId to check."
}

Give the command to a human, who sends it in the target chat. Then poll:

rustiz channels connect-status tg_…

The pending connection lives for one hour; each poll refreshes the TTL. A successful poll returns connected: true, the channel name and id, and the integration is stored immediately.

If you already know the numeric chat id, skip the word flow with a fields file:

cat > telegram.json <<'JSON'
{ "chatId": "-1001234567890" }
JSON

rustiz channels connect telegram --fields telegram.json

chatId may be a string or a JSON number. The snake_case alias chat_id is also accepted by the server code. The bot must already be a member of that chat.

MCP connectChannelTool

Start without credentials:

{ "provider": "telegram" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "tg_…",
  "word": "Ab3xY9zQ1p",
  "command": "/connect Ab3xY9zQ1p",
  "message": "Add @MyRustizBot … then send `/connect Ab3xY9zQ1p` …"
}

Poll with:

{ "connectionId": "tg_…" }

Polling is safe to repeat. The server calls getUpdates with the previous update offset, so already-seen updates are not returned again. When the command is found, Rustiz calls getChat, checks whether the bot is an administrator, optionally deletes the command/success messages, and saves the integration.

Direct connect:

{
  "provider": "telegram",
  "customFields": { "chatId": "-1001234567890" }
}

The server calls getChat with that id and saves the channel immediately. There is no name or description field for Telegram; those are Moltbook-only.

What the server does internally

connect.rs stores the pending state in the oauth_states table:

{ "word": "Ab3xY9zQ1p", "lastChatId": null }

lastChatId is misnamed: it is the update offset (last update_id + 1), not a chat id. On each poll, get_bot_id() calls:

getUpdates?offset=<lastChatId>&allowed_updates=["message","channel_post"]

and looks for an update whose message.text or channel_post.text is exactly /connect <word>. If found, it returns { "chatId": -100… }; otherwise it returns { "lastChatId": <last update_id + 1> } or {}.

If the bot is an administrator and has can_delete_messages, it deletes the /connect message, sends Connection Successful. Message will be deleted in 10 seconds., and schedules deletion of that notice after 10 seconds. If it is not an administrator, it sends a message asking the human to delete the connect messages manually. Either way the connection succeeds once the chat id is known.

Choosing the channel after connecting

is_between_steps = false; there is no channelSetupTool step and no channel picker. The chat id is the channel. To connect another group or channel, run the /connect flow again or pass a different chatId.

Telegram exposes no provider functions (functions() is empty), so triggerTool has nothing to call for this provider.

What not to send

  • externalUrl — Telegram is not self-hosted. The Telegram connect branch ignores it; use customFields.chatId or the /connect word instead.
  • customFields with a chatId when the bot is not already in that chat — getChat fails. Add the bot first, or use the word flow.
  • provider when polling — only connectionId is needed.

Post Settings & Media Validation

settings_schema()

{ "type": "object", "properties": {}, "additionalProperties": true }

There are no provider-specific settings. The destination is the chat id stored as the integration’s access token, not a settings.channel field. The engine adds "__type": "telegram" to the stored settings.

validate_settings()

Not overridden: every settings object is accepted. A post with {"channel":"..."} still publishes to the integration’s chat; the extra field is ignored.

check_validity()

Not overridden: no pre-flight media or length rules. The provider’s own process_media() can fail before the network call if a local path is outside the upload directory.

Media requirements

process_media() classifies each media item by extension:

ExtensionKindBot API method
png, jpg, jpeg, gif, webp, bmp, svg, tiff, tif, heicphotosendPhoto (single) / sendMediaGroup
mp4, m4v, mov, webm, mkv, avi, mpeg, mpgvideosendVideo / sendMediaGroup
anything elsedocumentsendDocument / sendMediaGroup

The send path depends on the number of media items:

  • 0 media: sendMessage with parse_mode=HTML.
  • 1 media: sendPhoto, sendVideo or sendDocument, with the text as the caption and parse_mode=HTML.
  • 2+ media: split into chunks of 10 and sent with sendMediaGroup. The caption is attached only to the first item of the first group; later groups have no caption, so the text is lost for items after the first 10.

Local vs remote files:

  • With STORAGE_PROVIDER=local, a media URL beginning with FRONTEND_URL is converted to a local path (the frontend prefix is stripped), validated against the upload directory, and uploaded as multipart.
  • Any other http(s) URL is passed to Telegram as a URL string; Telegram downloads it.
  • A non-URL path must exist under the upload directory, or process_media fails with a local media error.

Telegram’s own limits:

LimitValue
Text message1–4096 characters after HTML entity parsing
Media caption0–1024 characters
Upload via multipart10 MB for photos, 50 MB for other files
Send by URL5 MB for photos, 20 MB for other content
sendMediaGroup items2–10 per group
getFile download20 MB
Messages per second in one chatabout 1; short bursts tolerated
Messages per minute in a group20
Global bot broadcastabout 30 messages/second

The provider does not pre-check any of these. Telegram returns a 400 with a description; handle_errors() maps it to Telegram rejected the post: <description>, or to Telegram could not download your media file, please check the file and try again when the description is failed to get HTTP URL content.

HTML formatting

telegram_text() runs striptags(value, ["u", "strong", "p"]), then:

  1. <strong> → <b> and </strong> → </b>
  2. <p>…</p> → …\n

So the only tags preserved for Telegram are <u> and <b> (plus the temporary <p> conversion). Italics (<i>, <em>), links (<a href>), code blocks, lists and other HTML are stripped to their text. Telegram’s HTML parse mode supports more, but this port does not forward it.

Plain text is not HTML-escaped. A literal < or & in a message can make Telegram reject the post with a “can’t parse entities” error. Escape such text or use the Normal editor for content with raw angle brackets.

Threads and comments

supports_comments = true.

  • comment() computes reply_to = last_comment_id.unwrap_or(post_id), parses it as i64, drops 0, and sends the message with reply_to_message_id.
  • The first reply targets the original message; later replies target the previous comment’s message id, forming a reply chain.
  • If the id is not numeric (for example a legacy row), reply_to is None and the “comment” becomes a new top-level message.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • The bot token is the whole deployment. All channels share one bot. There is no per-channel bot token and no OAuth refresh. Rotating the token in BotFather breaks every Telegram channel until TELEGRAM_TOKEN is updated and the processes restart.
  • The bot token leaks through the integration picture. authenticate() resolves the chat photo with getFile and stores the returned link as the integration’s picture: https://api.telegram.org/file/bot<TELEGRAM_TOKEN>/<file_path>. That URL is returned by integrationList/channels list and is visible to any client that can read integrations. It contains the bot token. Treat the picture URL as a secret, or strip it before exposing integration metadata. The token also appears in the path of every Bot API request and may end up in error bodies or logs.
  • /connect matching is exact. The update text must be exactly /connect <word>. /connect@MyRustizBot <word>, extra spaces, or a different case do not match. Disable privacy mode for groups or the bot may never see the plain command.
  • Only one getUpdates consumer. A webhook or a second poller causes 409 Conflict. Call deleteWebhook before using Rustiz, and never share a bot token between staging and production. Updates are kept for 24 hours and are consumed by offset; a second consumer can steal the /connect message.
  • Pending state is one hour. The tg_… connection id is stored in oauth_states with TELEGRAM_TTL_MS = 60 * 60 * 1000. Each poll refreshes the TTL. If the human waits too long, start again; the word is not reusable after expiry.
  • lastChatId is an update offset. Do not read it as a chat id. It is fed back to getUpdates as offset, which confirms all earlier updates.
  • Connect can succeed without admin rights. get_bot_id() checks getChatMember and only uses admin status to decide whether to delete the connect messages. A group connection can succeed with a non-admin bot, but posting may still fail if the bot lacks send permissions.
  • Admin deletion needs can_delete_messages. Being administrator or creator is not enough; the code also requires result.can_delete_messages == true. Otherwise it leaves the messages and asks the human to remove them.
  • getChat failure is fatal. authenticate() calls getChat with the chat id. If the bot is not a member, the id is wrong, or the chat was deleted, the connect fails with the platform error. Add the bot first or use the word flow.
  • Chat id vs username. authenticate() stores id = chat.username when the chat has a public username, otherwise id = chat_id. The access token is always the numeric chat id. Posting uses the token, so both work. The stored internal_id is used for the release URL and for diagnostics.
  • Private-chat release URLs are wrong. release_link() uses https://t.me/{internal_id}/{message_id} whenever internal_id is non-empty and not "undefined". For a private channel or group without a username, internal_id is the negative chat id (e.g. -1001234567890), producing https://t.me/-1001234567890/42, which is not a valid Telegram link. The https://t.me/c/<id>/<message> fallback only runs for legacy rows whose internal_id is empty or "undefined". Public channels/groups with a username get correct t.me/<username>/<message> links.
  • Single media vs album. With one media item the text is a caption (max 1024). With multiple items the caption is on the first item of the first group only. A 1500-character post with one image fails even though max_length() allows 4096.
  • A trailing group of one item breaks albums. send_message() chunks media by 10 and calls sendMediaGroup for every chunk. Telegram requires 2–10 items per group. 11 media items produce groups of 10 and 1; the second call fails. 21 items produce 10, 10, 1; the last call fails. There is no rebalancing (e.g. 6+5).
  • Mixed media types can break albums. sendMediaGroup allows photos and videos together, but documents/audio can only be grouped with the same type. The provider does not group by kind; a mix of a PDF and a photo in one chunk is rejected.
  • Local media must be under the upload directory. For STORAGE_PROVIDER=local, a URL starting with FRONTEND_URL is converted to a local path; anything that does not start with http is validated with local_media_path(..., "telegram-error"). A path outside UPLOAD_DIRECTORY fails before Telegram is called.
  • Remote media is fetched by Telegram. A URL must be publicly reachable and must have the right MIME type. Telegram’s URL download limit is 5 MB for photos and 20 MB for other content, lower than the multipart limits. The provider maps failed to get HTTP URL content to a friendly error but cannot fix an unreachable URL.
  • No media retry. Text and single-part calls use Http::send, which retries 429/5xx. multipart() uses execute directly and does not retry; a transient 429 or 5xx on an upload becomes a BadBody (often with “Unknown Error”). Re-run the post if the media is safe to duplicate.
  • handle_errors() only handles HTTP 400. It parses error_code and description; any non-400 status is left to the shared HTTP layer. A 401 (invalid bot token) therefore becomes a RefreshToken error by the generic rule, but refresh_token() returns an empty token, so the channel is flagged for reconnection even though the only fix is to correct TELEGRAM_TOKEN.
  • No real token refresh. refresh_token() returns expires_in: Some(0) and an empty access token. Telegram bot tokens do not expire; the only lifecycle event is revocation/regeneration in BotFather.
  • generate_auth_url() is a stub. It returns the random state as the url field. The normal /integrations/social/telegram OAuth start route is not the supported path; use connectChannelTool, rustiz channels connect telegram, or POST /public/v1/integrations/telegram/connect, all of which go through connect::start()’s special case.
  • is_web3 = true is cosmetic. It is an upstream class flag; the connect layer special-cases Telegram and describe() reports telegramBot.
  • PostResponse::done() reports completed, not posted. Both Discord and Slack set PostStatus::Posted; Telegram uses the helper that sets PostStatus::Completed. Both are terminal for the engine, but a consumer that distinguishes the two states will see Telegram as completed.
  • Rate limits. Telegram’s documented limits are about 30 messages/second per bot globally, 20 messages/minute in a group, and 1 message/second in a single chat (short bursts tolerated). max_concurrent_job = 3 is conservative but a queue that targets one channel can still hit the per-chat limit. Telegram returns 429 with parameters.retry_after; the shared HTTP layer retries text calls, but uploads do not.
  • No analytics, mentions or plugs. Telegram has no analytics methods, no post analytics, supports_mentions = false, and no plug hooks in this port.
  • Release URL for a public channel depends on the username at connect time. If the channel later changes its username, the stored internal_id is stale and the release link breaks; posting still works because it uses the numeric chat id.
  • One bot per deployment is a hard operational rule. The Bot API is not multi-tenant. A single Rustiz deployment can connect many chats with one bot, but two deployments cannot share the token. Use separate bots for staging and production.

References

Threads (threads)

Threads uses the Threads API at graph.threads.net with a Threads user access token. Publishing is a two- or three-step container flow: create media containers, optionally create a carousel parent, then threads_publish. Rustiz splits this across the durable workflow (post_pending → check_post_status → finalize_post) so a crash after publish does not republish.

  • Rust source: crates/rustiz-providers/src/threads.rs
  • Upstream port: threads.provider.ts
  • Editor: Normal
  • Connection mode: oauth (Threads authorization window)

Overview

PropertyValue
Identifierthreads
Display nameThreads
Connection modeoauth
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries are published as replies with reply_to_id
Pending / async publishingYes. supports_pending = true; containers are created, polled, then published
AnalyticsYes. supports_analytics = true; threads_insights
Post analyticsYes. supports_post_analytics = true; /{post-id}/insights
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job20 (“Threads limits are per profile”)
Character limit (max_length)500 characters
API basehttps://graph.threads.net (THREADS_BASE_URL)
Web basehttps://www.threads.net (THREADS_WEB_BASE_URL)
Token refreshYes. refresh_cron = true; th_refresh_token

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["THREADS_APP_ID", "THREADS_APP_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
THREADS_APP_IDYes—The Threads App ID from App Dashboard → Threads API → Settings. This is not the Facebook app ID.
THREADS_APP_SECRETYes—The Threads App Secret from the same panel. It should be 32 characters; the UI truncates it unless you scroll.
THREADS_BASE_URLNohttps://graph.threads.netGraph host for token exchange, /v1.0/me, containers, publish, insights.
THREADS_WEB_BASE_URLNohttps://www.threads.netOrigin of /oauth/authorize and of the fallback profile URL.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI, wrapped in redirectmeto.com when not HTTPS.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Threads fetches media from a public URL.

Provider visibility warning from Postiz: if THREADS_APP_ID or THREADS_APP_SECRET is missing, the provider does not appear in the Add Channel list at all. Restart the process after adding them. rustiz providers --status reports the missing variables directly.

Step-by-Step Developer App Setup

1. Create the app with the Threads use case

  1. Go to https://developers.facebook.com/apps/creation/.
  2. Create an app and choose Access the Threads API (the Threads use case).
  3. Add Business details or skip. If the wizard says “There are no business portfolios available to connect to this app”, enable Two-Factor Authentication in Meta Business Suite and retry.
  4. Finish creating the app.

2. Configure API access

  1. On the app dashboard, open Access the Threads API.
  2. Add threads_content_publish. threads_basic is selected automatically. Add threads_manage_replies (reply/comments) and threads_manage_insights (analytics) for the full Rustiz feature set.
  3. Open the Settings tab and copy:
    • Threads App ID → THREADS_APP_ID
    • Threads App Secret → THREADS_APP_SECRET (use Show and copy all 32 characters)

3. Redirect URI (exact, including redirectmeto.com)

Threads::redirect_uri() is:

#![allow(unused)]
fn main() {
let frontend = self.ctx.config.frontend_url();
let frontend = if frontend.contains("https") {
    frontend
} else {
    format!("https://redirectmeto.com/{frontend}")
};
format!("{frontend}/integrations/social/threads")
}
FRONTEND_URLRegistered redirect URI
https://social.example.comhttps://social.example.com/integrations/social/threads
http://localhost:5000https://redirectmeto.com/http://localhost:5000/integrations/social/threads
http://localhost:4200https://redirectmeto.com/http://localhost:4200/integrations/social/threads

Register this exact value in Threads API → Settings → OAuth redirect URIs. The form also requires an Uninstall Callback URL and a Delete Callback URL; both can be the same value. Rustiz does not implement either callback, so they are only there to satisfy the form.

Click-to-activate quirk: Postiz’s guide notes that the redirect URI must be clicked in the dashboard to become active; typing it and saving without clicking can leave the form in a state where it looks saved but the URI is not registered.

The contains("https") test is a substring check, not a scheme parse. A FRONTEND_URL that includes https anywhere skips the redirectmeto wrapper. Like the Instagram standalone provider, the wrapper is evaluated per request; change FRONTEND_URL and you must re-register the URI.

4. Finish customization and restart

  1. Return to the app dashboard and click Finish customization; answer “Yes, I’m finished”. The API may not work until this wizard is complete.
  2. Restart rustiz serve, rustiz worker and rustiz mcp so the new environment variables are loaded.

5. Scopes requested and checked

Threads::scopes() returns, in this order:

#![allow(unused)]
fn main() {
vec![
    "threads_basic",
    "threads_content_publish",
    "threads_manage_replies",
    "threads_manage_insights",
]
}

The authorize URL joins them with commas:

https://www.threads.net/oauth/authorize
  ?client_id={THREADS_APP_ID}
  &redirect_uri={redirect_uri}
  &state={state}
  &scope=threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights

check_scopes() is never called by this provider. The token exchange response is not inspected for granted scopes. A user who declines threads_manage_insights still connects; analytics then fails at call time and returns [].

ScopeWhy Rustiz needs itApp Review for non-testers
threads_basic/me, all endpointsYes
threads_content_publishCreate/publish containersYes
threads_manage_repliesPost replies/commentsYes
threads_manage_insightsAccount and post insightsYes

Threads testers can grant these at any time while the app is in Development mode. For anyone without a role, each permission must be approved through App Review and the app must be published.

6. Threads testers

  1. App Dashboard → App roles → Roles → Testers → Add People.
  2. Under Additional Roles for this App, choose Threads Tester.
  3. Enter the Threads username (not the Facebook/Meta developer account).
  4. On threads.com, open Settings → Account → Website permissions → Invites (https://www.threads.com/settings/website_permissions) and accept.
  5. Test the connection. The Open Graph API Explorer can generate a Threads access token for the tester as a sanity check (select threads.net v1).

If the invite is not accepted, the login window fails with an authorization error even though the app credentials are correct.

7. Development mode vs Live / App Review

  • Development mode (default): only Threads testers can authorize and publish. This is enough for a self-hosted single-user install.
  • Live mode: anyone can authorize, but only permissions approved through App Review are active. Advanced Access requires App Review; review needs a screencast of the Threads login and a published post/reply.
  • A permission grant by a public Threads profile is valid for 90 days and can be extended by refreshing the token. A private profile’s grant cannot be extended; the user must re-authorize.

8. Sandbox / testing

Threads has no separate sandbox host. Use a Threads tester account, a test app in Development mode, and publish test posts. Container publishing is visible immediately after threads_publish; there is no draft state.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect threads
rustiz channels connect threads --open
rustiz channels connect threads --timeout 600

The login URL is printed to stderr. With a local FRONTEND_URL the CLI serves the callback on the frontend port and waits; with a public HTTPS FRONTEND_URL it prints the URL and exits.

is_between_steps is false, so the channel is finished when the callback returns. No setup tool is needed.

MCP connectChannelTool

{ "provider": "threads" }

Response:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://www.threads.net/oauth/authorize?client_id=…&redirect_uri=…&state=…&scope=threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with { "connectionId": "…" }. integrationSchema reports oauth, headless: false, requiresHuman: true, requiresExternalUrl: false.

There is no triggerTool / channelSetupTool follow-up.

What not to send

  • customFields routes to connect_custom() and is treated as an OAuth code. Never send it to threads.
  • externalUrl is unused.

Post Settings & Media Validation

settings_schema()

Threads exposes no provider-specific settings:

{
  "type": "object",
  "properties": {},
  "additionalProperties": true
}
  • validate_settings() is the trait default: every object is accepted.
  • check_validity() is the trait default: no pre-flight media checks.
  • functions() is the trait default: triggerTool has no Threads-specific helper to call (no page, board or channel picker).
  • settings is effectively {"__type": "threads"} plus anything the caller adds; unknown keys are ignored by the provider.

Not exposed even though the Threads API supports them: is_made_with_ai, topic_tag, link_attachment, gif_attachment, polls, spoilers, location tagging, and quote posts. quote_post_id is implemented in the low-level create_text_content() helper but no public setting or post field feeds it.

Character limit

max_length() is 500 for every post. Threads counts emoji as their UTF-8 byte length, while Rustiz’s engine counts Unicode characters; a post full of emoji can pass local validation and still be rejected with “text must be at most 500 characters”.

Media requirements

Rustiz does not validate media for Threads. The platform requires:

MediaRule
ImageJPEG or PNG, ≤ 8 MB, width 320–1,440 px, aspect ratio ≤ 10:1, sRGB
VideoMOV or MP4, H.264/HEVC + AAC, 23–60 fps, ≤ 1 GB, 5 minutes max, width ≤ 1,920
Carousel2–20 images/videos; a carousel counts as one post
Text≤ 500 characters
LinksAt most 5 unique URLs per post; more fails with THREADS_API__LINK_LIMIT_EXCEEDED
Alt textNot sent by Rustiz (MediaContent.alt is ignored)

How the containers are built:

  • No media → POST /v1.0/{user-id}/threads as multipart/form-data with media_type=TEXT, text, access_token, optional reply_to_id and quote_post_id.
  • One media → POST /v1.0/{user-id}/threads?image_url=… or video_url=…, plus media_type=IMAGE|VIDEO, text, access_token. is_carousel_item is omitted, which means the API treats it as a single post.
  • More than one media → one child container per item with is_carousel_item=true and media_type=IMAGE|VIDEO, then the parent media_type=CAROUSEL&children=<ids>&text=….

Child text quirk: every carousel child is created with the full post text as its text parameter, not just the parent. Threads documents text as optional on children and normally ignores it, but if a child text causes a validation error in a future API version, this is the place to look.

Threads / comments

supports_comments = true. A multi-entry post:

  1. The first entry is published as a text/image/video/carousel post.
  2. Each later entry is created with reply_to_id set to the previous post id (or the root id for the first reply) and published with threads_publish.
  3. comment() uses create_thread_content() then publish_thread(), which waits for the container to be FINISHED (or PUBLISHED) and then publishes.
  4. Replies count against the profile’s 1,000 replies/24 h limit.

Plugs

MethodKindFieldsEffect
autoPlugPost@Plug (run_plug)likesAmount, postReads GET /v1.0/{post-id}/insights?metric=likes; when the first value is ≥ likesAmount, posts a text reply with reply_to_id and publishes it

run_post_plug() always returns Unsupported. likesAmount accepts a string or number; a missing value becomes 0.

Implementation Notes & Gotchas

  • Three-step durable publish. post_pending() creates either one container (step: "container") or all carousel child containers (step: "children"). check_post_status() reads one status per child (or the single container) and returns Pending on IN_PROGRESS. finalize_post() creates the carousel parent (returning Pending again), then calls threads_publish once the parent is ready.
  • PUBLISHED means “already live”. If check_post_status() sees a container in PUBLISHED, it returns Completed immediately. For a single container it uses the container id as the post id and the profile URL as the release URL — it does not call the API again. For carousel parents the same is true. This is the crash-recovery path, not the normal path.
  • Carousel parent is polled. After finalize_post() creates the parent, it returns Pending; the workflow checks the parent, and only a FINISHED parent is published. A crash between parent creation and publish leaves an invisible parent that expires.
  • Arm/confirm is not used. Unlike X/Pinterest/Facebook stories, Threads has no attempting/confirmed handshake. Duplicate protection relies on the container status: a PUBLISHED container is never published again. A crash after threads_publish but before recording the result can therefore report the container id rather than the published post id; the post is still live.
  • check_loaded() waits up to ~5.5 minutes (150 iterations × 2.2 s) and then errors “Threads took too long to process the media, please try again”. publish_thread() calls it before every publish, including comments.
  • Status states. FINISHED → ready; PUBLISHED → ready/already live; ERROR/EXPIRED → error using error_message unless it is empty or UNKNOWN; anything else → in progress. A missing status is treated as in-progress until the budget runs out.
  • Authorize URL omits response_type=code. Meta’s Threads documentation lists response_type=code as required on the authorization window. The Rust URL sends client_id, redirect_uri, state and scope only. The endpoint currently defaults to the code flow, but if the login window rejects the request, add &response_type=code to generate_auth_url().
  • Token exchange uses GET, not the documented POST form. Meta’s reference shows POST https://graph.threads.com/oauth/access_token with form fields; Rustiz calls the same endpoint with query parameters via c.get(...). Meta accepts this for Threads today. If you see an OAuth error at this step, switching to a POST form is the first compatibility change to try.
  • No scope check. As noted above, the provider never verifies granted scopes. A token without threads_manage_insights connects fine and analytics silently returns [] (the error is logged).
  • Token refresh is real. refresh_cron = true; shortly before the hard coded 58-day expiry the hourly sweep calls GET /refresh_access_token?grant_type=th_refresh_token&access_token=…, then re-reads /me. Meta’s long-lived token is valid 60 days; refreshing extends it (and the 90-day permission grant for public profiles).
  • Long-lived exchange has no client id. The second step is GET /access_token?grant_type=th_exchange_token&client_secret=…&access_token=…. Only the app secret and short-lived token are sent, matching Meta’s Threads documentation.
  • /me has no name. fetch_user_info() requests id,username,threads_profile_picture_url and sets name = username. The channel display name is therefore the handle, not a display name.
  • refresh_token in AuthTokenDetails is the access token. There is no separate refresh token in the Threads API. expires_in is hard-coded to 58 days.
  • max_concurrent_job = 20. Threads limits publishing per profile to 250 posts/24 h and 1,000 replies/24 h. A carousel counts as one post. The worker concurrency is separate from those quotas; the provider does not query threads_publishing_limit.
  • Analytics quirks. Account analytics uses GET /v1.0/{id}/threads_insights?metric=views,likes,replies,reposts,quotes&period=day&since=…&until=…. If a metric returns total_value, one point is emitted dated today; otherwise it reads values[] and dates them from end_time. Post analytics uses GET /v1.0/{post-id}/insights?metric=views,likes,replies,reposts,quotes and prefers values[0].value, falling back to total_value.value. All errors are swallowed and return []; percentage change is 5.0 (account) or 0.0 (post).
  • threads_insights vs insights. The account endpoint is threads_insights; the per-post endpoint is insights. Mixing them returns a permission/unknown-metric error.
  • Release URL fallback. thread_permalink() reads GET /v1.0/{thread-id}?fields=id,permalink. If that fails, it returns https://www.threads.net/@{profile}. A live post is never failed because of a missing permalink.
  • Error mapping. Error validating access token → refresh; 2207051 → “We restrict certain activity to protect our community”; 4279013 → user restricted; “The media could not be fetched from this URI” → bad media URL; 4279009 → retry (“could not find the media container”); “text must be at most 500 characters” → bad body. A 429 falls through to the generic retry policy; there is no X-Business-Use-Case-Usage backoff.
  • Link limit. Threads rejects posts with more than 5 unique URLs. Rustiz does not count links, so a link-heavy post fails at container creation with THREADS_API__LINK_LIMIT_EXCEEDED (not mapped by handle_errors; it surfaces as the raw Graph error).
  • Carousel limit is not checked. Rustiz accepts any number of media; the API rejects fewer than 2 or more than 20 children.
  • Media must be public. Threads (like Instagram) downloads the image/video from the URL. A Rustiz instance on a private network needs a public media host or tunnel.
  • THREADS_APP_ID is not the Facebook app ID. Using the Facebook pair produces an “Invalid client_id” or “App not authorized” error at the login window. Copy the values from the Threads API Settings tab.
  • No sandbox. Test with Threads testers; Development mode hides content from non-role users.

References

TikTok (tiktok)

tiktok is the TikTok for Developers (Login Kit + Content Posting API) provider. It publishes videos from a local file (FILE_UPLOAD) and photo carousels from public URLs (PULL_FROM_URL), either directly to the profile (DIRECT_POST) or as a draft in the TikTok app inbox (UPLOAD). Publishing is two-phase: post_pending registers the upload and returns pending; the engine polls the read-only status endpoint. There is no finalize step, because the publish call is irreversible and a retried mutation could post twice.

Do not confuse this with tiktok-business: a different developer app, a different API host, different OAuth endpoints, a trailing-slash redirect URI, and Access-Token headers instead of Bearer.

  • Rust source: crates/rustiz-providers/src/tiktok.rs
  • Upstream port: tiktok.provider.ts
  • Editor: Normal
  • Connection mode: oauth

Read the gotchas first. Until the app passes TikTok’s Content Posting API audit, every post is forced to SELF_ONLY and only private accounts can post, with a 5-users-per-24 h cap. Photo posts only work with a verified URL property (domain or URL prefix). When FRONTEND_URL is not HTTPS the login goes through redirectmeto.com, and that relayed URL must be registered byte-for-byte.

Overview

PropertyValue
Identifiertiktok
Display nameTiktok (the display string in ProviderInfo)
Connection modeoauth (authorization code; no PKCE challenge)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingYes. supports_pending = true; post_pending → check_post_status; no finalize_post
AnalyticsYes. supports_analytics = true; profile counts plus the last 20 videos
Post analyticsYes. supports_post_analytics = true; views/likes/comments/shares for one video
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegtrue — PNG uploads are converted to JPEG by the engine before publishing
max_concurrent_job10000 (effectively unlimited; TikTok’s own per-user limits are the real constraint)
Character limit (max_length)2000 characters (stricter than TikTok’s 2200 video / 4000 photo-description limits)
APIhttps://open.tiktokapis.com (v2)
OAuth scopesvideo.list, user.info.basic, video.publish, video.upload, user.info.profile, user.info.stats
Token lifetime24 h; the port records 23 h (23 * 3600), but refresh is reactive (on the first expired call), not scheduled

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
&["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
TIKTOK_CLIENT_IDYesnoneDeveloper app Client key (16 characters in Postiz’s docs).
TIKTOK_CLIENT_SECRETYesnoneDeveloper app Client secret (32 characters in Postiz’s docs).
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI; also decides whether redirectmeto.com is prefixed. Trailing / stripped.
TIKTOK_BASE_URLNohttps://open.tiktokapis.comAPI origin. Paths appended include /v2/oauth/token/, /v2/user/info/, /v2/post/publish/..., /v2/video/list/, /v2/video/query/, /v2/post/publish/status/fetch/, /v2/post/publish/creator_info/query/.
TIKTOK_WEB_BASE_URLNohttps://www.tiktok.comAuthorize origin /v2/auth/authorize/ and the base of release URLs.

required_env_alternatives() is not overridden.

rustiz providers --status | jq '.[] | select(.identifier=="tiktok")'

Step-by-Step Developer App Setup

1. Create the app

  1. Sign up / log in at https://developers.tiktok.com/.
  2. Create or join an organization (recommended; an individual-owned app is allowed but not recommended for production).
  3. Open https://developers.tiktok.com/apps → Connect an app, choose the owner organization.
  4. Fill in App details:
    • app icon 1024×1024, JPEG/JPG/PNG, ≤ 5 MB;
    • app name, category, description (shown on the consent screen);
    • Platforms → Web and the official website URL.
  5. Add public Terms of Service and Privacy Policy URLs on HTTPS. Apps created after 9 Sep 2024 must verify these URL properties before review.

2. Add products

  • Login Kit (Web): add the redirect URI (next section). This grants user.info.basic by default.
  • Content Posting API: add it and enable the Direct Post configuration. This is what makes /video/init/ and /content/init/ usable.
  • Optionally Display API for video.list (analytics/video queries).

3. Add the scopes

Under Scopes, add all six:

user.info.basic
user.info.profile
user.info.stats
video.list
video.publish
video.upload

video.publish is the Direct Post scope and requires approval. video.upload is the inbox/draft scope. video.list powers analytics and the “missing posts” lookup. user.info.stats is needed for follower/like counts.

The Postiz TikTok page lists video.create. That is a legacy name; TikTok’s current scope reference has video.list (read public videos), which is what the Rust code requests and checks. Request exactly the six scopes above.

4. Register the redirect URI

TikTok Login Kit requires absolute HTTPS, ≤ 512 characters, static (no query parameters), no fragment, at most 10 URIs. The Rust code builds:

{FRONTEND_URL}/integrations/social/tiktok

with one twist:

#![allow(unused)]
fn main() {
let prefix = if frontend.contains("https") { "" } else { "https://redirectmeto.com/" };
format!("{prefix}{frontend}/integrations/social/tiktok")
}
  • FRONTEND_URL=https://social.example.com → https://social.example.com/integrations/social/tiktok.
  • FRONTEND_URL=http://localhost:5000 → https://redirectmeto.com/http://localhost:5000/integrations/social/tiktok. redirectmeto.com is a public HTTPS redirector: TikTok sees an HTTPS URL, the human’s browser is then 302’d to localhost. The exact redirectmeto URL must be registered in Login Kit, and it is also what is sent to the token endpoint, so do not edit it.
  • The check is frontend.contains("https"), a substring test on the whole URL, not a scheme check. A hostname containing https would suppress the relay.
  • Localhost is not accepted by TikTok directly; use the relay or a public HTTPS tunnel/domain. Media, separately, must always be public HTTPS.

5. Verify URL properties (required for Content Posting)

In the app page → URL properties → Verify properties (Production mode):

  • Verify the website, Terms and Privacy URLs.
  • For photos and any PULL_FROM_URL video, verify the domain or URL prefix that serves your media. Domain verification uses a DNS TXT signature; URL-prefix verification uses a signature file uploaded under the prefix. Subdomains are covered by a domain verification, but a different path is not covered by a URL prefix.
  • The media URL must be https, must not redirect (3xx is invalid), and must stay reachable for up to one hour while TikTok downloads it.

If you skip this, the post fails with url_ownership_unverified or photo_pull_failed.

6. Copy credentials

App page → Credentials:

TIKTOK_CLIENT_ID="your-client-key"
TIKTOK_CLIENT_SECRET="your-client-secret"
FRONTEND_URL="https://social.example.com"

Restart Rustiz and check rustiz providers --status.

Sandbox / testing mode

TikTok for Developers has a real sandbox:

  1. On the app page, toggle Production → Sandbox.
  2. Create Sandbox (up to 5 sandboxes), optionally cloning the production configuration. Edit app details/products in the sandbox and Apply changes.
  3. Under Target users, click Add account and log in with each TikTok account you want to test with (up to 10 accounts). The account owner agrees to the Developer Terms.
  4. Authorize each target user through Login Kit in the sandbox.
  5. URL verification in a sandbox is only required for the Content Posting API; the ToS/privacy/website checks are production-review requirements.

Sandbox limitations:

  • TikTok’s sandbox is a restricted environment: expect Content Posting results to remain private (SELF_ONLY) rather than public, even after sandbox setup. It is for validating the integration/UX, not for public posting.
  • Sandbox configuration is separate; changes do not affect the live app.
  • When satisfied, import the sandbox configuration into a production draft and submit it for review.

Production app review and the Content Posting audit

These are two separate gates:

  1. App review (Products/Scopes). Submit under App review with:
    • a written explanation of how each product and scope is used;
    • at least one demo video (up to 5, ≤ 50 MB each) showing the complete end-to-end flow, including the consent screen and the posting UX. Statuses: Draft → In review → Live (or Not approved with comments). Any later change must be submitted as a revision.
  2. Content Posting API audit. This is separate from app review and is what lifts the unaudited restrictions:
    • unaudited clients may only publish SELF_ONLY;
    • every posting account must be private at posting time;
    • at most 5 users may post through the client in any 24 h window;
    • attempting a public level fails with unaudited_client_can_only_post_to_private_accounts. Apply at https://developers.tiktok.com/application/content-posting-api. The audit checks the Content Sharing Guidelines: show which account receives the post, let the creator choose privacy/interaction settings, obtain consent before upload, and never add promotional watermarks.

Other limits that surface as errors: ~15–25 posts/account/day, at most 5 pending shares in any 24 h (spam_risk_too_many_pending_share), and a daily active-user cap (reached_active_user_cap). The Rust provider maps all of these to actionable BadBody messages.

Exact OAuth scopes and checks

#![allow(unused)]
fn main() {
info.scopes = vec![
    "video.list",
    "user.info.basic",
    "video.publish",
    "video.upload",
    "user.info.profile",
    "user.info.stats",
];
}

The authorization URL joins them with commas (scope=a,b,c), URL-encoded. At token exchange check_scopes(&self.info.scopes, token["scope"]) requires all six; commas or spaces are accepted in the response. If the user declines one scope, the connection fails with not_enough_scopes even though TikTok may have issued a usable token for the others.

The token request is form-encoded and includes:

client_key, client_secret, code, grant_type=authorization_code,
code_verifier=<state>, redirect_uri=<computed redirect>

There is no PKCE challenge in the authorization URL; code_verifier is sent but is not tied to a challenge. TikTok ignores it today.

Connecting a Channel (CLI & MCP)

CLI

# Opens the TikTok consent page in the default browser.
rustiz channels connect tiktok --open

# Prints the URL to stderr and waits for the callback in-process.
rustiz channels connect tiktok

# Remote server: open the URL yourself, then poll.
rustiz channels connect-status <connectionId>

Do not pass --fields (it switches to the custom-credentials branch) or --external-url (unsupported).

In local/in-process mode the CLI serves the callback on the FRONTEND_URL port when that is http://localhost/http://127.0.0.1. With the default http://localhost:5000 the login URL is the redirectmeto URL, but the browser still ends up on localhost, so the CLI can finish the flow. A remote server must serve the callback itself.

MCP

Start:

{ "provider": "tiktok" }

Result (or a URL elicitation):

{
  "connected": false,
  "pending": true,
  "connectionId": "abc123",
  "url": "https://www.tiktok.com/v2/auth/authorize/?client_key=...&redirect_uri=...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll:

{ "connectionId": "abc123" }

is_between_steps is false: there is no channelSetupTool step. Once connected, integrationList returns the channel id. The channel’s profile field is the TikTok username, which is used to build post URLs.

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredBehavior
titlestring (≤ 90)NoPhoto DIRECT_POST: sent as post_info.title, truncated to 90 chars. Photo UPLOAD: sent as the title, not truncated. Video: ignored — the post message becomes the title. Validation still enforces ≤ 90 even when it will be ignored.
privacy_levelenum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLYYesOnly when DIRECT_POST. Defaults to PUBLIC_TO_EVERYONE at publish time if missing, but validation requires a value.
duetbooleanYesVideos only, DIRECT_POST only. Sent as disable_duet = !duet. Ignored for photos.
stitchbooleanYesVideos only, DIRECT_POST only. Sent as disable_stitch = !stitch. Ignored for photos.
commentbooleanYesBoth media types, DIRECT_POST only. Sent as disable_comment = !comment.
autoAddMusicenum "yes", "no"YesPhotos only, DIRECT_POST only. "yes" sends auto_add_music: true (TikTok picks recommended music). Ignored for videos.
brand_content_togglebooleanYesPaid-partnership disclosure; DIRECT_POST only.
brand_organic_togglebooleanYesOwn-business promotion disclosure; DIRECT_POST only.
video_made_with_aibooleanNoVideos only, DIRECT_POST only. Sent as is_aigc. Not sent for photos (TikTok’s photo endpoint has a top-level is_aigc, which this port does not use).
musicobject {id, title?, artist?, image?, audio_volume?, video_volume?}NoDeclared for TikTok Business only. Legacy tiktok.rs never reads it. music.id must be non-empty if the object is present.
locationobject {id, name, address?}NoDeclared for TikTok Business only. Legacy tiktok.rs never reads it. id and name must be non-empty.
content_posting_methodenum DIRECT_POST, UPLOADYesDIRECT_POST publishes; UPLOAD sends the media to the user’s TikTok inbox and TikTok ignores every other setting. The draft expires after 24 h if the user does not finish it.

The schema also lists privacy_level, duet, stitch, comment, autoAddMusic, brand_content_toggle, brand_organic_toggle, content_posting_method in required, and has additionalProperties: true.

The schema is duplicated in tiktok-business, so music and location appear here even though only the Business provider implements them. Passing them to tiktok is silently ignored.

validate_settings() rules

  • The whole settings value must be a JSON object.
  • title, if non-null, must be a string of at most 90 characters.
  • privacy_level is required and must be one of the four enum values.
  • duet, stitch, comment, brand_content_toggle, brand_organic_toggle are required booleans (missing or wrong type → error).
  • video_made_with_ai, if present and non-null, must be a boolean.
  • autoAddMusic is required and must be exactly "yes" or "no".
  • content_posting_method is required and must be "DIRECT_POST" or "UPLOAD".
  • music, if present and non-null, must be an object with a non-empty string id; title/artist/image must be strings when present; audio_volume/video_volume must be numbers (or numeric strings) in 0..=100.
  • location, if present and non-null, must be an object with non-empty string id and name; address must be a string when present.

The DTO requires the interaction booleans even for UPLOAD, where they are discarded. Agents must send a complete settings object.

check_validity() rules

  • No media → "No video / images selected".
  • More than one media item and any video → "Only pictures are supported when selecting multiple items".
  • More than one media item where the first is a video → "You need one media".
  • All images: every image is measured; the first whose shorter side > 1080 produces "Image N is WxH, TikTok allows a maximum of 1080px on the shorter side". This catches the all-or-nothing picture_size_check_failed before publish.

There is no upper bound on the number of images for tiktok (the Business provider checks 35). TikTok’s photo endpoint allows up to 35, so a larger carousel fails at the API with invalid_params.

Media rules and publish behavior

Videos

  • Detected by has_extension(path, "mp4") — a case-insensitive substring check. A URL without .mp4 is treated as a photo.
  • Local files and public URLs both work; the bytes are streamed from storage.
  • source_info is FILE_UPLOAD with video_size, chunk_size, total_chunk_count:
    • ≤ 64 MiB → one chunk (chunk_size = video_size, count 1);
    • 64 MiB → 10 MiB chunks and count = size / 10 MiB (floor). The final chunk absorbs the remainder and may exceed chunk_size (TikTok allows up to 128 MiB for the last part).

  • Upload PUT requests send Content-Type: video/mp4, Content-Length and Content-Range: bytes start-end/size; 200/201/206 are accepted.
  • A TikTok rejection (BadBody) fails the post. A network error during upload is treated as ambiguous: the provider returns pending and lets the status endpoint decide, because TikTok may already have all the bytes.
  • video_cover_timestamp_ms is sent when the media carries a thumbnailTimestamp.
  • TikTok’s documented limits are a maximum duration of 3, 5 or 10 minutes depending on the creator, 23–60 fps, 360–4096 px per side and ≤ 4 GB; the Rust port does not validate these.

Photos

  • Photo posts only support PULL_FROM_URL. source_info.photo_images is the list of media paths as stored (typically your public upload/CDN URLs), photo_cover_index: 0, source: "PULL_FROM_URL".
  • post_mode is DIRECT_POST or MEDIA_UPLOAD (note: not "UPLOAD").
  • The endpoint is always /v2/post/publish/content/init/.
  • Requirements: WebP or JPEG, ≤ 20 MB per image, shorter side ≤ 1080 px, up to 35 images, public HTTPS and ownership-verified. The engine’s convert_to_jpeg = true rewrites PNG uploads to JPEG first.
  • is_aigc is not sent for photos.

Endpoint selection

content_posting_methodMediaEndpoint
DIRECT_POSTvideoPOST /v2/post/publish/video/init/
DIRECT_POSTphotoPOST /v2/post/publish/content/init/
UPLOADvideoPOST /v2/post/publish/inbox/video/init/
UPLOADphotoPOST /v2/post/publish/content/init/ (post_mode: MEDIA_UPLOAD)

What the body actually sends

DIRECT_POST video:

{
  "post_info": {
    "title": "<the whole post message>",
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "disable_duet": false,
    "disable_comment": false,
    "disable_stitch": true,
    "is_aigc": false,
    "brand_content_toggle": false,
    "brand_organic_toggle": true,
    "video_cover_timestamp_ms": 1234
  },
  "source_info": { "source": "FILE_UPLOAD", "video_size": 123, "chunk_size": 123, "total_chunk_count": 1 }
}

DIRECT_POST photo:

{
  "post_info": {
    "title": "<settings.title, 90 chars>",
    "description": "<the whole post message>",
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "disable_comment": false,
    "auto_add_music": true,
    "brand_content_toggle": false,
    "brand_organic_toggle": true
  },
  "source_info": {
    "source": "PULL_FROM_URL",
    "photo_cover_index": 0,
    "photo_images": ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"]
  },
  "post_mode": "DIRECT_POST",
  "media_type": "PHOTO"
}

UPLOAD sends only title (video) or title + description (photo), plus the source info. Every privacy/interaction/brand/music setting is discarded by TikTok.

Status, release resolution and analytics

check_post_status makes one read-only call to POST /v2/post/publish/status/fetch/ with { "publish_id": ... }:

StatusResult
SEND_TO_USER_INBOXCompleted with post_id = "missing" and release_url = https://www.tiktok.com/messages?lang=en#<publish_id>. The draft has no public id until the user publishes it.
PUBLISH_COMPLETEIf the raw body contains a numeric publicaly_available_post_id (TikTok’s typo), that id and the profile URL with /video/<id> or /photo/<id>; otherwise the publish id and https://www.tiktok.com/@<username>. The public id only appears after moderation.
FAILEDBadBody, with handle_errors mapping known reasons.
anything else (including PROCESSING_*)Pending — the engine polls again.

resolve_release_id handles a channel whose release id was "missing":

  • the publish id is read from the URL fragment (.../messages#<publish_id>);
  • only ids containing _pub_ or _inbox_ are considered;
  • for non-drafts, SELF_ONLY and MUTUAL_FOLLOW_FRIENDS immediately return Unavailable (TikTok never exposes a public id for non-public posts);
  • if the status has a public id → Resolved; draft with none → None; non-draft with none → Pending.

release_url() queries /v2/video/query/?fields=id,share_url and strips the query string from share_url — it carries TikTok tracking parameters, including the app’s client key.

analytics():

  • /v2/user/info/?fields=follower_count,following_count,likes_count,video_count → Followers, Following, Total Likes, Videos;
  • the last 20 videos from /v2/video/list/ are queried with id,like_count,comment_count,share_count,view_count and summed into Views, Recent Likes, Recent Comments, Recent Shares.

post_analytics() queries the same fields for one video and returns Views, Likes, Comments, Shares. Analytics errors are swallowed (logged, empty array), so a missing scope looks like “no data”, not an error.

missing() returns the last 20 videos as (id, cover_image_url) so a "missing" release can be matched manually.

Plugs

Neither run_plug() nor run_post_plug() is overridden. Both use the trait defaults and return Unsupported for every method. TikTok has no auto-repost or auto-comment plug in this port (the upstream provider does not define them either).

Implementation Notes & Gotchas

  • The redirect URI is a third-party dependency when FRONTEND_URL is not HTTPS. https://redirectmeto.com/ is in the live login path. It must be reachable by the human’s browser, and the exact relayed URL must be registered in Login Kit. If redirectmeto.com is down, local/HTTP connects fail. Set an HTTPS FRONTEND_URL to remove the dependency.
  • Redirect URI substring check. frontend.contains("https") decides the relay; it is not a URL parse. A FRONTEND_URL such as http://https.localhost:5000 would skip the relay and TikTok would reject the URI.
  • expires_in is hard-coded to 23 h. TikTok access tokens live 24 h. Since refresh_cron is false, there is no proactive refresh; the first API call after expiry fails with access_token_invalid, the provider maps it to RefreshToken, the engine refreshes and retries. Refresh tokens rotate; the new one is stored.
  • open_id is normalized by removing dashes. TikTok open ids are app-scoped: the same TikTok account connected through a different client key gets a different open_id, so it becomes a separate Rustiz channel. Reusing the same app is required for continuity.
  • The open id is also the channel identity for analytics. Changing apps means reconnecting; the old channel’s analytics will not match.
  • video.list is requested even for posting. If TikTok grants video.publish/video.upload but not video.list, check_scopes refuses the connection. This is deliberate (analytics and missing need it), but it makes scope approval stricter than a publish-only integration.
  • settings.title is mostly a photo field. For videos the title is the post message, so a >90-char title setting fails validation for nothing. For photos it is truncated to 90, while the description (the message) is capped by max_length = 2000, below TikTok’s 4000.
  • music and location are dead settings on this provider. They exist in the shared schema but tiktok.rs never reads them. Use tiktok-business for commercial music and location tags.
  • autoAddMusic only applies to photos. For videos the field is validated but never sent.
  • video_made_with_ai only applies to videos. The photo endpoint accepts a top-level is_aigc, which this port does not send.
  • UPLOAD is not publishing. It creates an inbox draft that the user must finish in the TikTok app within 24 h. The status becomes SEND_TO_USER_INBOX, the release id is "missing", and settings are ignored. Use it only when the user explicitly asks to review in the app.
  • Photo posts are URL pulls, not uploads. A local UPLOAD_DIRECTORY file path is not readable by TikTok. Your media must be served over public HTTPS from a verified domain/prefix. This is the most common cause of photo_pull_failed / url_ownership_unverified.
  • Validation is shallow. No file size, duration, fps, resolution (video) or MIME check beyond the .mp4 substring and the photo shorter-side rule. TikTok rejects the publish later with file_format_check_failed, duration_check_failed, frame_rate_check_failed, etc.; handle_errors maps those to readable messages.
  • spam_risk_too_many_pending_share means 5 pending drafts in 24 h. The provider’s message tells the user to open the TikTok inbox and try again after 24 h. reached_active_user_cap (unaudited daily user cap) is mapped to Disconnect, which cannot actually help; reconnecting does not reset the cap.
  • post_url depends on integration.profile. It must contain the TikTok username (set from user/info at connect time). If it is empty, release URLs become https://www.tiktok.com/@/video/....
  • The public-id regex relies on a TikTok typo. It matches "publicaly_available_post_id" (missing the second l) in the raw response, exactly as TikTok emits it, because JSON numbers in that array can exceed 2^53 and lose precision if parsed normally.
  • Error identifier typo. The FAILED status branch uses ProviderError::bad_body("titok-error-upload", ...) (missing a t), while the upload path uses "tiktok-error-upload". Cosmetic, but it changes the error identifier in logs.
  • Status polling is single-shot by design. No loops or sleeps in check_post_status; the engine’s durable timer re-runs it. A stuck or retried check can never re-run the publish, which prevents duplicates. The blocking post() path loops 27 times / 8 minutes with 20 s sleeps.
  • Analytics swallows every non-token error. Missing scopes, rate limits and transient failures all become []. Check the logs if the analytics panel is empty.
  • Creator info is never queried before publishing. post_pending sends the configured privacy_level directly; it does not call /v2/post/publish/creator_info/query/ and does not check the account’s privacy_level_options, comment_disabled, duet_disabled or max_video_post_duration_sec. TikTok’s Direct Post UX guidelines require that query, and the API returns privacy_level_option_mismatch when the chosen level is not offered. The only caller of creator_info/query is the unexposed max_video_length(). This is also a common reason a Content Posting audit is rejected.
  • max_video_length() is not exposed. It calls /v2/post/publish/creator_info/query/ and returns max_video_post_duration_sec, but TikTok does not override functions(), so triggerTool cannot invoke it. It is only reachable from Rust code.
  • Length checks count Unicode scalar values, not UTF-16 code units. The engine’s max_length check and take_chars count chars, while TikTok’s photo title limit is 90 UTF-16 runes. A title made of emoji (2 code units each) can pass validate_settings at 90 characters and still exceed TikTok’s 90-rune limit. The same applies to the 2000-character message check versus TikTok’s 2200/4000 UTF-16 limits.
  • Rate limits. The Content Posting API allows 6 requests per minute per user access token; status checks are a separate, higher per-endpoint limit (commonly 30/min). The blocking path polls every 20 s (3/min) and stays under both. TikTok also caps daily posts per account (~15–25) and pending drafts (5 per 24 h).
  • convert_to_jpeg only rewrites PNGs. prepare_post_media converts PNG files to JPEG and re-stores them when UPLOAD_DIRECTORY is set; other formats are passed through. TikTok’s accepted photo formats are WebP and JPEG, so a GIF photo post will fail at TikTok even though validation accepts it.

References

TikTok Business (tiktok-business)

tiktok-business uses the TikTok API for Business — TikTok Account / Business Account surface, not the TikTok for Developers Login Kit surface. It posts to a TikTok Business Account through https://business-api.tiktok.com/open_api/v1.3.

Everything is different from tiktok: a separate developer app and credentials, a separate OAuth token endpoint that accepts JSON, Access-Token headers instead of Authorization: Bearer, response envelopes that report failures as HTTP 200 with a non-zero code, and a redirect URI that must end with a slash.

  • Rust source: crates/rustiz-providers/src/tiktok_business.rs
  • Upstream port: tiktok.business.provider.ts
  • Editor: Normal
  • Connection mode: oauth

Critical deployment note. TikTok Business requires the registered redirect URL to end in /. The Rust code therefore builds .../integrations/social/tiktok-business/. The Rust server registers only /integrations/social/{provider} (no trailing slash), and axum/matchit match paths exactly — so the callback to .../tiktok-business/?auth_code=... has no matching route and returns 404. Put a reverse-proxy rewrite in front (strip the trailing slash before forwarding) or add a route alias before relying on this provider. See Implementation Notes & Gotchas.

Overview

PropertyValue
Identifiertiktok-business
Display nameTikTok (the ProviderInfo name is literally “TikTok”; use the identifier to tell it apart)
Connection modeoauth (authorization code; no PKCE)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingYes. supports_pending = true; post_pending → check_post_status; no finalize_post
AnalyticsYes. supports_analytics = true; profile counts plus the last 20 videos
Post analyticsYes. supports_post_analytics = true; one video’s views/likes/comments/shares
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegtrue
max_concurrent_job10000
Character limit (max_length)2200 characters (Business video caption; photo captions allow 4000, but the port uses 2200 for both)
APIhttps://business-api.tiktok.com/open_api/v1.3
OAuth scopesuser.info.basic, user.info.username, user.info.stats, video.list, video.upload, video.publish
Token lifetime24 h access token, 1 year refresh token; the port records 23 h

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
&["TIKTOK_BUSINESS_CLIENT_ID", "TIKTOK_BUSINESS_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
TIKTOK_BUSINESS_CLIENT_IDYesnoneBusiness developer app Client ID.
TIKTOK_BUSINESS_CLIENT_SECRETYesnoneBusiness developer app Client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the trailing-slash redirect URI; decides whether redirectmeto.com is prefixed. Trailing / stripped before the provider path and final slash are appended.
TIKTOK_BUSINESS_BASE_URLNohttps://business-api.tiktok.com/open_api/v1.3API origin including the /open_api/v1.3 prefix. The provider appends /business/..., /tt_user/oauth2/..., /discovery/....
TIKTOK_BUSINESS_WEB_BASE_URLNohttps://www.tiktok.comAuthorize origin (/v2/auth/authorize/) and release-URL base.

These are deliberately not shared with tiktok: a Business app cannot be used with the Login Kit provider, and vice versa.

rustiz providers --status | jq '.[] | select(.identifier=="tiktok-business")'

Step-by-Step Developer App Setup

1. Business account and developer registration

  1. Create a TikTok for Business account at https://business.tiktok.com/ (the account whose organic posts you will publish).
  2. Register as a developer in the TikTok API for Business portal at https://business-api.tiktok.com/portal. The Business API’s own guide gives the order: create a TikTok for Business account → register as a developer → create a developer app → authorization. Use a Business app here; do not reuse the consumer Login Kit app’s credentials.
  3. Create an organization/company profile if the portal asks for one. App ownership should be the organization, not a personal developer account.

2. Create the developer app

  1. My Apps → Create app / Connect an app.
  2. Fill in app name, description, website, privacy policy and Terms of Service URLs (HTTPS, public, verified).
  3. Add the TikTok Account / Business Account capability (the organic posting product; portal labels vary between “TikTok Account”, “Business Account” and “Accounts API”). The endpoints used by this provider are:
    • /business/get/ — profile information;
    • /business/video/list/ — video list and per-video metrics;
    • /business/video/publish/ — video publish;
    • /business/photo/publish/ — photo publish;
    • /business/publish/status/ — publish status;
    • /business/publish/location/ — location search;
    • /discovery/cml/trending_list/ — commercial music library.
  4. Add the six scopes from the next section. video.publish and video.upload are the posting scopes; video.list and user.info.* power analytics and the account profile.
  5. Submit the app for review. Depending on the data you access, TikTok may also require its Data security and privacy review (and, for US data, the US data security review). Approval is not instant.

3. Configure the TikTok account holder redirect URL

This is the step that differs most from Login Kit. In My Apps → App Detail → Basic Information, the portal shows a TikTok account holder authorization URL derived from the redirect URL you register. TikTok’s documented rules:

RuleValue
Absolute and ends with /https://dev.example.com/auth/callback/
Static; query parameters denied.../callback/?id=1 is invalid
No fragment/#.../callback/#1 is invalid
Must start with https://http://... is invalid
No porthttps://dev.example.com:3000/... is invalid
Length 10–512 characters
Up to 10 URLs per app, one active at a timeactivating a URL regenerates the authorization URL

Rustiz builds exactly:

{prefix}{FRONTEND_URL}/integrations/social/tiktok-business/

where prefix is empty for an HTTPS FRONTEND_URL, or https://redirectmeto.com/ otherwise. Examples:

https://social.example.com/integrations/social/tiktok-business/
https://redirectmeto.com/http://localhost:5000/integrations/social/tiktok-business/

Register the exact string, including the final /. The same string is sent to /tt_user/oauth2/token/ as redirect_uri, so a mismatch produces TikTok’s redirect_uri error.

Do not use an HTTPS FRONTEND_URL with a port. The Business redirect rules forbid ports, so https://social.example.com:8443/... is rejected by the portal. Terminate TLS on 443 in front of Rustiz.

4. Copy credentials and configure Rustiz

From the app’s Basic Information / Credentials:

TIKTOK_BUSINESS_CLIENT_ID="your-client-id"
TIKTOK_BUSINESS_CLIENT_SECRET="your-client-secret"
FRONTEND_URL="https://social.example.com"

Restart, then check rustiz providers --status.

Sandbox / testing mode

  • The TikTok API for Business sandbox accounts you may find in the portal are ad-account sandboxes for the Marketing API, not an organic posting sandbox. TikTok has announced they will be deprecated by 1 December 2026 and recommends creating your own test account in production.
  • There is no separate sandbox for the TikTok Account/Business Account endpoints used here. The practical test path is:
    1. get the app and the six scopes approved;
    2. connect your own Business Account;
    3. post with content_posting_method: "UPLOAD" first (a private draft in the account’s inbox) to validate the pipeline without publishing;
    4. then test DIRECT_POST.
  • Use a secondary Business Account if you do not want test content on the main profile. The API has no “test post” flag.

Production app review

  • The app must be approved for the TikTok Account capability and all six scopes. Unapproved scopes fail at authorize time or at the first API call.
  • Expect the Business API’s data security and privacy review for production access; the portal’s guide lists it as a distinct step after app creation.
  • Unlike the Login Kit Content Posting API, this provider has no separate “audit to lift SELF_ONLY” gate in the code. Visibility comes from the Business Account and the privacy_level you send for photos; videos have no privacy field at all and use the account default.
  • Re-review is required for changes to a live app, as with any TikTok app.

Exact OAuth scopes and checks

#![allow(unused)]
fn main() {
info.scopes = vec![
    "user.info.basic",
    "user.info.username",
    "user.info.stats",
    "video.list",
    "video.upload",
    "video.publish",
];
}

The authorization URL joins them with commas. At token exchange the provider passes response["data"]["scope"] to check_scopes; all six are required, and the helper accepts either comma- or space-separated values. user.info.username is what populates the channel’s username/profile, which is used in release URLs.

The authorization URL is built on TIKTOK_BUSINESS_WEB_BASE_URL (default https://www.tiktok.com):

{web}/v2/auth/authorize/?client_key=<id>&redirect_uri=<encoded>&state=<16 chars>
  &response_type=code&disable_auto_auth=1&scope=a,b,c

Note disable_auto_auth=1: the consent page is always shown, even for a member with an existing grant. The token exchange posts JSON to {base}/tt_user/oauth2/token/:

{
  "client_id": "...",
  "client_secret": "...",
  "grant_type": "authorization_code",
  "auth_code": "...",
  "redirect_uri": ".../integrations/social/tiktok-business/"
}

authenticate() rejects an empty auth_code before calling TikTok, because a declined consent otherwise surfaces as TikTok’s unhelpful Please provide the auth_code (code 40002).

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect tiktok-business --open
rustiz channels connect tiktok-business
rustiz channels connect-status <connectionId>

Do not pass --fields or --external-url.

Because of the trailing-slash route mismatch described at the top, a direct rustiz serve / rustiz mcp deployment will receive /integrations/social/tiktok-business/?auth_code=... as a 404. Before testing:

  • put a rewrite in the reverse proxy, for example Caddy:

    rewrite /integrations/social/tiktok-business/ /integrations/social/tiktok-business
    

    (or equivalent nginx: rewrite ^/integrations/social/tiktok-business/$ /integrations/social/tiktok-business break;), while leaving the outward-facing redirect URI with the slash; or

  • add a server route alias for the trailing-slash path.

The token exchange still uses the trailing-slash redirect_uri, which is what TikTok validates, so the proxy rewrite only affects the local route.

MCP

Start:

{ "provider": "tiktok-business" }

Poll:

{ "connectionId": "abc123" }

There is no channelSetupTool step (is_between_steps = false). Once connected, integrationList returns the channel; its profile is the TikTok username and its internal id is the Business open_id (also the business_id for every API call).

Post Settings & Media Validation

settings_schema() and validate_settings() are duplicated from the TikTok provider and are identical (see tiktok.md), including the music and location fields, which this provider actually implements.

settings_schema()

FieldTypeRequiredBusiness behavior
titlestring (≤ 90)NoPhotos only: post_info.title, truncated to 90. Videos have no title; the caption is post_info.caption.
privacy_levelenum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLYYesPhotos only. Videos have no privacy field and publish with the account default. Defaults to PUBLIC_TO_EVERYONE if omitted at publish time, but validation requires it.
duetbooleanYesVideos, DIRECT_POST only → disable_duet = !duet.
stitchbooleanYesVideos, DIRECT_POST only → disable_stitch = !stitch.
commentbooleanYesBoth media types, DIRECT_POST only → disable_comment = !comment.
autoAddMusic"yes" / "no"YesPhotos, DIRECT_POST only. "yes" attaches a random commercial music library track (Rustiz picks it, not TikTok) and overrides an explicit music selection. If no track can be found it falls back to auto_add_music: true.
brand_content_togglebooleanYesSent as is_branded_content for both media types.
brand_organic_togglebooleanYesSent as is_brand_organic for both media types.
video_made_with_aibooleanNoVideos, DIRECT_POST only → is_ai_generated. Not supported for photos.
musicobject {id, title?, artist?, image?, audio_volume?, video_volume?}Nomusic.id is a commercial-library track from musicSearch. Videos: music_sound_info with music_sound_id, music_sound_volume, video_original_sound_volume (defaults 50, not the API’s 0/muted). Photos: music_sound_info with only music_sound_id; ignored when autoAddMusic is "yes".
locationobject {id, name, address?}Nolocation_id + location_name from locationSearch. Photos and videos, DIRECT_POST only.
content_posting_methodDIRECT_POST / UPLOADYesUPLOAD saves a draft (upload_to_draft for video, is_draft for photo) and TikTok ignores every other setting.

validate_settings() rules

Same as tiktok:

  • object required;
  • title ≤ 90 characters when present;
  • privacy_level required and one of the four values;
  • duet, stitch, comment, brand_content_toggle, brand_organic_toggle required booleans;
  • video_made_with_ai boolean when present;
  • autoAddMusic required, exactly "yes"/"no";
  • content_posting_method required, DIRECT_POST/UPLOAD;
  • music.id non-empty; music strings typed; volumes numeric or numeric strings in 0..=100;
  • location.id and location.name non-empty; address a string when present.

check_validity() rules

  • No media → "No video / images selected".
  • More than one media with any video → "Only pictures are supported when selecting multiple items".
  • More than one media whose first item is a video → "You need one media".
  • More than 35 images → "You can select up to 35 pictures" (Business-only check; the legacy TikTok provider does not enforce it).
  • All-image posts: each image is measured; the first with a shorter side > 1080 px → "Image N is WxH, TikTok allows a maximum of 1080px on the shorter side".

Media and publish behavior

Videos — POST /business/video/publish/:

{
  "business_id": "<open_id>",
  "video_url": "https://cdn.example/video.mp4",
  "custom_thumbnail_url": "https://cdn.example/thumb.jpg",
  "post_info": {
    "caption": "the post message",
    "is_brand_organic": true,
    "is_branded_content": false,
    "disable_comment": false,
    "disable_duet": false,
    "disable_stitch": true,
    "is_ai_generated": true,
    "location_id": "...",
    "location_name": "...",
    "music_sound_info": {
      "music_sound_id": "...",
      "music_sound_volume": 50,
      "video_original_sound_volume": 50
    }
  }
}
  • video_url is a URL, not a file upload. There is no FILE_UPLOAD path in the Business API: a local filesystem path will not work. Serve the video from a public HTTPS URL.
  • Thumbnail precedence: custom_thumbnail_url (from media.thumbnail) wins; otherwise thumbnail_offset (from media.thumbnail_timestamp, only when non-zero).
  • upload_to_draft: true replaces all the interaction fields for UPLOAD.
  • No privacy_level. Do not expect the photo privacy setting to affect videos; the account default applies.

Photos — POST /business/photo/publish/:

{
  "business_id": "<open_id>",
  "photo_images": ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"],
  "photo_cover_index": 0,
  "post_info": {
    "title": "a title",
    "caption": "the post message",
    "is_brand_organic": true,
    "is_branded_content": false,
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "disable_comment": false,
    "auto_add_music": false,
    "location_id": "...",
    "location_name": "...",
    "music_sound_info": { "music_sound_id": "..." }
  }
}
  • Cover is always the first image (photo_cover_index: 0); there is no setting to choose another.
  • auto_add_music is only a fallback: if a random track was found, the flag is false and music_sound_info carries the chosen track.
  • is_draft: true replaces the interaction fields for UPLOAD.

Media requirements

  • Videos: MP4/H.264 recommended; 23–60 fps; 360–4096 px per side; maximum duration depends on the account (3/5/10 min). The file must be a public HTTPS URL; the Business API downloads it (video_url), there is no byte upload.
  • Photos: JPEG or WebP; ≤ 20 MB each; shorter side ≤ 1080 px; up to 35 images; public HTTPS URLs in photo_images. The engine’s convert_to_jpeg = true rewrites PNG uploads to JPEG.
  • The media host must be reachable by TikTok’s servers without redirects. A signed URL is fine if it stays valid for the download window.

Publish response and status

  • A publish failure is an HTTP 200 body with code != 0 or a missing data.share_id. The provider classifies it itself: RefreshToken/Disconnect become provider errors; everything else becomes BadBody with TikTok’s message.
  • post_pending stores { "publishId": <share_id> }.
  • check_post_status calls GET /business/publish/status/?business_id=<open_id>&publish_id=<share_id>:
    • SEND_TO_USER_INBOX → Completed with post_id = "missing" and https://www.tiktok.com/messages?lang=en#<share_id>;
    • PUBLISH_COMPLETE → numeric post_ids[0] from the raw body (regex, to avoid int64 precision loss) → https://www.tiktok.com/@<username>/video|photo/<id>; if absent → share id and https://www.tiktok.com/@<username>;
    • FAILED → BadBody using reason + the envelope;
    • anything else → Pending.
  • resolve_release_id only handles ids containing _pub_/_inbox_; for non-drafts whose publish id starts with p_pub_ and privacy is SELF_ONLY or MUTUAL_FOLLOW_FRIENDS, it returns Unavailable without a call.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
musicSearchgenre? (e.g. POP, ROCK, ELECTRONIC)[{id, title, artist, image, duration, previewUrl}]Calls /discovery/cml/trending_list/?business_id=...&genre=.... There is no text query; filter locally. duration is returned in milliseconds (TikTok’s seconds × 1000). Non-token errors return []; token errors surface as RefreshToken.
locationSearchq (required, truncated to 100 chars)[{id, name, address}]Calls /business/publish/location/?business_id=...&search_query=.... Empty q returns []. Non-token errors return [].

MCP:

{
  "integrationId": "int_...",
  "methodName": "locationSearch",
  "dataSchema": [{ "key": "q", "value": "Milan" }]
}

Use the returned id/name in the location setting and the returned track id in music.

Analytics

  • analytics():
    • /business/get/?fields=["followers_count","following_count","total_likes","videos_count"] → Followers, Following, Total Likes, Videos;
    • the last 20 videos from /business/video/list/ with item_id,likes,comments,shares,video_views → Views, Recent Likes, Recent Comments, Recent Shares (summed).
  • post_analytics() reuses /business/video/list/ with a filters={"video_ids":[post_id]} and returns Views, Likes, Comments, Shares.
  • missing() lists the last 20 videos with item_id,thumbnail_url.
  • Token errors (RefreshToken/Disconnect) surface from analytics, so the caller can refresh and retry. All other errors are logged and returned as [].

Plugs

Neither run_plug() nor run_post_plug() is overridden. Both use the trait defaults and return Unsupported for every method. There is no auto-repost or auto-comment plug for TikTok Business in this port.

Implementation Notes & Gotchas

  • Trailing-slash redirect vs. the Rust route (deployment blocker). The code builds .../tiktok-business/ because TikTok Business requires it. The server route is /integrations/social/{provider} with no alias. Matchit 0.8 is exact (/path/foo/{arg} does not match /path/foo/myarg/), so the callback 404s. Fix it at the proxy or add a route. This also affects the CLI’s in-process callback server, which uses the same router.
  • FRONTEND_URL with an HTTPS port is invalid for Business. The redirect rules forbid ports. https://host:8443/... is rejected when registering the URL. Use 443.
  • redirectmeto.com still applies for HTTP FRONTEND_URLs. The computed URL is https://redirectmeto.com/http://localhost:5000/integrations/social/tiktok-business/. The port is inside the relayed path, so the redirect URI itself has no port and satisfies TikTok’s rule. Register it exactly.
  • Every API failure is HTTP 200. handle_errors is driven by the body, and each call site checks body["code"] == 0. Do not rely on HTTP status codes in logs; read code/message.
  • business_id is the open_id, verbatim. details() deliberately does not strip dashes (unlike tiktok, which removes them) because the value is reused as business_id on every call. Changing the app changes the open_id and creates a new channel.
  • Token refresh is JSON, not form-encoded. POST /tt_user/oauth2/refresh_token/ with client_id, client_secret, grant_type=refresh_token, refresh_token. Refresh tokens last a year but rotate; the response’s open_id may be used to keep the identity. expires_in is hard-coded to 23 h; refresh_cron is false, so refresh is reactive on the first expired call.
  • TIKTOK_BUSINESS_BASE_URL must include /open_api/v1.3. The provider appends paths beginning with /business/... and /tt_user/...; setting it to just https://business-api.tiktok.com produces 404s.
  • TIKTOK_BUSINESS_WEB_BASE_URL is the authorize host, not the API host. Default https://www.tiktok.com. Overriding it also changes release-URL bases and the messages URL.
  • No creator-info query. The provider sends the chosen privacy_level (photos only) and interaction flags without asking TikTok which options the account supports. If the account does not offer the level, the publish fails with privacy_level_option_mismatch (or the Business equivalent) rather than being corrected up front.
  • No video privacy control. privacy_level is a photo-only field on this API. A “private” video setting in the UI does nothing; the account default applies. Plan tests accordingly.
  • Photo captions allow 4000, the port allows 2200. max_length is shared with the video-caption limit, so longer photo captions are rejected by Rustiz before TikTok sees them. The title is truncated to 90; the caption is not truncated, just length-checked.
  • autoAddMusic = "yes" overrides music. This is intentional: the UI hides the track picker when random music is on, so a stale selection must not win. For photos, a random commercial track is attached; for videos the setting is ignored entirely.
  • Music volumes default to 50. The Business API defaults both volumes to 0 (muted); the provider follows the TikTok app’s default of 50 instead, so selecting a track without volumes does not produce a silent video. Precisely: a missing key becomes 50, explicit null/false becomes 0, and an unparsable value or an array/object becomes JSON null (JSON has no NaN).
  • musicSearch is trending-only. No free-text search; genre is the only filter. duration is multiplied by 1000 and may be 0 for a clip with no duration.
  • locationSearch truncates the query to 100 characters and returns [] for an empty query or a non-token API error.
  • video_url / photo_images must be publicly fetchable. Unlike the legacy provider’s FILE_UPLOAD, the Business API pulls media server-side. Local paths, localhost URLs and private bucket URLs fail with video_pull_failed/photo_pull_failed or url_ownership_unverified.
  • Draft field names differ by media type. Video uses upload_to_draft; photo uses is_draft. Both discard the interaction/privacy/music/location fields.
  • thumbnail_offset is omitted when 0. custom_thumbnail_url wins over the timestamp. If neither exists, TikTok picks a frame.
  • The 35-image limit is Business-only. The legacy provider’s validation skips it, so a 36-image legacy photo post is rejected later by TikTok.
  • Status PUBLISH_COMPLETE can arrive before post_ids. The public id may lag up to ~3 minutes, and non-public posts never get one. The provider falls back to the profile URL and keeps the share id. resolve_release_id can fill the id in later.
  • Error identifier. Publish failures use "tiktok-business-error-upload"; token/API errors elsewhere use "tiktok-business".
  • Analytics token errors are not swallowed. Non-token errors become [], but RefreshToken/Disconnect propagate so the engine refreshes once and retries. The legacy provider swallows everything instead.
  • Length checks count Unicode scalar values, not UTF-16 code units. The title/caption checks count chars; TikTok’s photo title is 90 UTF-16 runes and the photo description is 4000. An emoji-heavy title/caption can exceed TikTok’s count while passing Rustiz.
  • Rate limits are per app and per endpoint. TikTok returns rate_limit_exceeded or Too many requests; the provider maps them to a BadBody “TikTok API rate limit exceeded” message. The Content Posting API’s 6/min-per-user rule does not apply to this Business surface; the Business portal’s global/endpoint limits do.
  • Business sandbox accounts are for ads. They do not create a sandbox for organic posting, and TikTok plans to deprecate them on 1 Dec 2026.

References

Tumblr (tumblr)

Tumblr is an OAuth 2.0 provider with a two-step connect flow: authorize the app, then choose which blog the channel should post to. Posts use Tumblr’s Neue Post Format (NPF) content blocks, and media is streamed as a multipart upload (the JSON body is one part, each attachment another). One Tumblr account can become several channels, one per blog.

  • Rust source: crates/rustiz-providers/src/tumblr.rs
  • Upstream port: tumblr.provider.ts
  • Editor: Normal (plain text)
  • Connection mode: oauth
  • Post-connect step: yes (is_between_steps = true)

Overview

PropertyValue
Identifiertumblr
Display nameTumblr
Connection modeoauth (authorization code, refresh token)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first PostDetails is published; extra parts are not supported
Pending / async publishingNo. supports_pending = false; returns PostStatus::Completed
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepstrue — the OAuth login is not enough; the blog must be selected with pages + channelSetupTool
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)32768 characters
Text block limit4096 characters per text block (long text is chunked)
APITumblr API v2, NPF post creation

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["TUMBLR_CLIENT_ID", "TUMBLR_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden. If either is missing, provider_status refuses to generate a login URL and tells you to register the redirect URI.

VariableRequiredDefaultPurpose
TUMBLR_CLIENT_IDYesnoneTumblr app’s OAuth Consumer Key.
TUMBLR_CLIENT_SECRETYesnoneTumblr app’s OAuth Consumer Secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the OAuth redirect URI. Must be the public origin Tumblr can reach, with no trailing slash (the code trims one). The callback is served at /integrations/social/tumblr.
TUMBLR_BASE_URLNohttps://api.tumblr.com/v2Overrides the API origin. Include the /v2 path: the override replaces the whole base and the code appends /oauth2/token, /user/info, /blog/....
TUMBLR_WEB_BASE_URLNohttps://www.tumblr.comOverrides the web origin used to build /oauth2/authorize.
ALLOW_PRIVATE_NETWORKNofalseOnly relevant if you point a base URL at a local mock; not needed for real Tumblr.

required_env() does not include FRONTEND_URL, but an OAuth redirect cannot work without it. The default produces http://localhost:5000/integrations/social/tumblr, which is fine only for a local browser login.

Step-by-Step Developer App Setup

  1. Sign in to Tumblr and open https://www.tumblr.com/oauth/apps.

  2. Click Register application (or Register a new application).

  3. Fill in:

    • Application name — required, for example Rustiz.

    • Application description — required.

    • Website — required; your product or instance URL.

    • Callback URL / Redirect URI — required. Enter exactly:

      {FRONTEND_URL}/integrations/social/tumblr
      

      For example https://social.example.com/integrations/social/tumblr. Do not add a trailing slash. The path is generated by format!("{}/integrations/social/tumblr", config.frontend_url()) and frontend_url() strips any trailing slash from FRONTEND_URL.

  4. Save the app. Tumblr shows an OAuth Consumer Key and an OAuth Consumer Secret (the secret is behind Show secret key).

  5. Put them in the server environment:

    TUMBLR_CLIENT_ID="your-oauth-consumer-key"
    TUMBLR_CLIENT_SECRET="your-oauth-consumer-secret"
    FRONTEND_URL="https://social.example.com"
    
  6. Restart Rustiz. Check readiness:

    rustiz providers --status | jq '.[] | select(.identifier=="tumblr")'
    

    It should report "configured": true and no missing variables.

Products and APIs

Tumblr has no “products” to enable in the app console. The app is a single OAuth 2.0 client. The scopes are requested at authorization time, not stored on the app.

Exact OAuth scopes

Tumblr’s scopes() returns:

#![allow(unused)]
fn main() {
vec!["write", "offline_access"]
}

The authorization URL sends scope=write+offline_access (space-delimited in the URL). authenticate() calls check_scopes(&["write", "offline_access"], token["scope"]); the token response must contain both tokens, split on a comma if present, otherwise on spaces. A response with only write fails with not_enough_scopes.

  • write is required to create posts.
  • offline_access is required to receive a refresh token. Without it, the 42-minute access token cannot be renewed and the channel stops working.

Redirect URI details

The exact URI constructed by the Rust code is:

{FRONTEND_URL (trailing slash trimmed)}/integrations/social/tumblr

Examples:

DeploymentRedirect URI to register
Production https://social.example.comhttps://social.example.com/integrations/social/tumblr
Local default FRONTEND_URLhttp://localhost:5000/integrations/social/tumblr
Postiz docs local examplehttp://localhost:4200/integrations/social/tumblr
Postiz docs Docker examplehttp://localhost:4007/integrations/social/tumblr

The last two are only examples of different FRONTEND_URL values; the source of truth is always your FRONTEND_URL. The same URI is sent in the authorization request and in the token exchange, so it must match the app’s callback URL exactly. There is no redirectmeto.com (or other relay) prefix: Rustiz builds the URI directly from FRONTEND_URL. If FRONTEND_URL is http://localhost:5000, the redirect is HTTP; a production https://... frontend produces an HTTPS redirect and must be registered as such.

Sandbox / testing mode

Tumblr has no sandbox environment and no separate test app mode.

  • The OAuth app is usable immediately after registration; there is no review queue.
  • Use a secondary Tumblr account or a secondary blog for tests. A post is published immediately (state: "published"); there is no draft or queue setting in this provider.
  • The pages function lets you choose a non-primary blog, so a test blog can be connected without touching the main blog.
  • Rate-limit removal is a manual request, not an app review: on https://www.tumblr.com/oauth/apps, use Request rate limit removal under the app (it opens an email to api_ratelimit@tumblr.com). Default limits are 1,000 API calls per hour and 5,000 per day per consumer key.

Production app review

There is no formal app review. The app can authorize users as soon as it is registered. Production concerns are the default rate limits, Tumblr’s posting limits (for example 250 new posts per day, 250 images, 20 videos and 60 minutes of video per day), and the app being suspended for abuse. All are platform policy, not an approval step in Rustiz.

Connecting a Channel (CLI & MCP)

CLI

# Opens the Tumblr authorization page in the default browser.
rustiz channels connect tumblr --open

# Without --open: the URL is printed to stderr and the command waits.
rustiz channels connect tumblr

# If the server is remote (--url), the CLI cannot see the finished login:
# open the URL yourself, then poll.
rustiz channels connect-status <connectionId>

In local/in-process mode the CLI serves the callback on the port of FRONTEND_URL (if it is this machine) and waits up to --timeout seconds (default 300) for the new channel to appear. On success it prints a JSON document with connected: true and the channel.

The OAuth result initially represents the Tumblr user, not a blog. The channel is marked in_between_steps, so it still needs the blog selection below before it can publish.

The Rustiz CLI has no channel setup / trigger command. A Tumblr channel started with rustiz channels connect can finish OAuth, but it remains in_between_steps; complete it with the MCP triggerTool + channelSetupTool calls below (or an MCP/API client that exposes them). Until then, publishing is blocked with Channel setup is not finished.

MCP

Start the OAuth flow:

{ "provider": "tumblr" }

The tool returns one of:

  • a URL elicitation to clients that support it, or

  • a normal result:

    {
      "connected": false,
      "pending": true,
      "connectionId": "abc123",
      "url": "https://www.tumblr.com/oauth2/authorize?...",
      "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
    }
    

Give the URL to a human, then poll:

{ "connectionId": "abc123" }

After the login, call integrationList and take the Tumblr channel id.

Finish the blog selection (is_between_steps)

  1. List the blogs with triggerTool and the pages function:

    {
      "integrationId": "int_...",
      "methodName": "pages"
    }
    

    Each result looks like:

    {
      "id": "alice",
      "name": "Alice's Blog",
      "username": "https://alice.tumblr.com/",
      "followers": 12,
      "primary": true,
      "picture": { "data": { "url": "https://api.tumblr.com/v2/blog/alice.tumblr.com/avatar/128" } }
    }
    

    id is the blog name (the *.tumblr.com subdomain), not the numeric id and not the display title. name is the display title.

  2. Choose one and call channelSetupTool:

    {
      "integrationId": "int_...",
      "data": { "id": "alice" }
    }
    

    fetch_page_information() looks up that id in pages(), then finish_integration_setup() updates the channel:

    • internalId = blog name;
    • name = blog name (not the display title);
    • picture = avatar URL;
    • profile = blog URL;
    • inBetweenSteps = 0.
  3. Verify with integrationList. Until this step is done, validatePostTool reports Channel setup is not finished and scheduled posts fail.

A successful setup returns:

{ "success": true, "name": "alice" }

Provider functions (triggerTool)

FunctionParametersReturnsNotes
pagesnoneArray of {id, name, username, followers, primary, picture.data.url}Calls GET /user/info with the channel token and maps every blog. id is the blog name; username is the blog URL; picture.data.url is the avatar endpoint.

pages has an empty input_schema / dataSchema. It is the only provider function; there is no comment/thread function.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

Every field is optional; an empty settings object is valid.

FieldTypeRequiredValidation / behavior
titlestringNominLength: 1, maxLength: 4096. Published as the first content block, {"type":"text","subtype":"heading1","text":...}.
linkstringNoOptional URL. Published as a {"type":"link","url":...} block after the text. A value without http gets https:// prepended. Empty is ignored.
sourceUrlstringNoOptional attribution URL, sent as source_url. A value without http gets https:// prepended. Empty is ignored.
tagsstringNomaxLength: 4096. A comma-separated string, not an array. Sent as tags unchanged; Rustiz does not split, trim or validate the individual tags.

Example object form:

{
  "title": "Behind the process",
  "link": "https://example.com/article",
  "sourceUrl": "https://example.com/original",
  "tags": "design, typography, process"
}

MCP settings array:

{
  "settings": [
    { "key": "title", "value": "Behind the process" },
    { "key": "tags", "value": "design, typography, process" }
  ]
}

The engine adds "__type": "tumblr" automatically if missing.

validate_settings() rules

  1. If settings is not an object, validation returns Ok(()) (no fields to check).
  2. For each of title, link, sourceUrl, tags:
    • a missing or null value is skipped;
    • any present value must be a string.
  3. title must be between 1 and 4096 characters inclusive.
  4. tags must be at most 4096 characters.
  5. link / sourceUrl, when non-empty, must match the optional-URL rule: ^((https?://)|www\.) followed by a host label, a dot and at least two non-space characters. Empty strings are allowed and ignored.

Content blocks and publish behavior

create_content_blocks() builds the NPF content array in this order:

  1. title as a text / heading1 block (if non-empty).
  2. The message split on runs of two or more newlines, each part trimmed and chunked to 4096 Unicode code points; each chunk is a text block.
  3. link as a link block (if non-empty).
  4. One block per media item:
    • .mp4 → {"type":"video","provider":"tumblr","media":{"type":...,"identifier":"media-N","width","height"}};
    • everything else → {"type":"image","media":[{...}],"alt_text"?}.

If the resulting array is empty, a single empty text block is sent so Tumblr accepts the post.

The payload is:

{
  "content": [ ...blocks... ],
  "state": "published",
  "tags": "design, process",
  "source_url": "https://example.com/original"
}

tags and source_url are omitted when empty.

  • If there is no media, the payload is sent as JSON to POST /blog/{blog}/posts.
  • If there is media, the same JSON is sent as the json multipart part, plus one media-N part per attachment. Files are streamed from the media store/URL; each retry re-opens the streams. The response must be a JSON object with a response object, otherwise the provider raises Tumblr did not return a valid post response.
  • The post id is read from response.id_string, then response.post_id, then response.id.
  • The release URL is {integration.profile}/post/{postId} with a trailing slash removed from the profile. After blog setup, profile is the blog URL (for example https://alice.tumblr.com). If it is missing, the fallback is {TUMBLR_WEB_BASE_URL}/{id}/post/{postId}, which is not the blog URL — this is why the setup step matters.

Media requirements

  • check_validity() counts the first post’s media:
    • more than 30 images → Tumblr supports up to 30 images in one post.;
    • more than 1 video → Tumblr supports one uploaded video in one post..
  • “Video” is detected only by a case-insensitive .mp4 substring in the path. .mov and .webm are treated as images even though mime_type() knows their MIME types; image_dimensions() will normally fail on them.
  • Images require readable dimensions. The provider calls self.ctx.media.image_dimensions(path) and fails the post if it cannot determine width/height.
  • Video dimensions come from media.thumbnail when present; otherwise they default to 540×405. A thumbnail that cannot be read also falls back to the default.
  • MIME type is inferred from the path extension: png, jpg/jpeg, gif, webp, bmp, svg, mp4, mov, webm; unknown extensions become application/octet-stream.
  • Tumblr’s platform limits apply in addition: up to 30 images or one video, daily media/video upload limits, and the blog queue limit. The provider maps Tumblr error codes 8004 (daily media), 8011 (daily video), 8022 (queue) and related codes to specific messages.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Two-step connect is mandatory. OAuth alone leaves in_between_steps = true. You must call pages and then channelSetupTool with the blog name. A channel in this state is listed but cannot publish.
  • OAuth id is the Tumblr user, not the blog. authenticate() sets id/name/username from the Tumblr user’s name. Only channelSetupTool changes internalId to the selected blog name and profile to the blog URL. Do not use the OAuth result’s id as a blog name.
  • The blog selector’s id is the subdomain. pages() returns id: blog.name; name is the display title. channelSetupTool expects {"id":"alice"}, not {"id":"Alice's Blog"} and not a numeric id.
  • Rate limit is per consumer key. Tumblr’s default is 1,000 calls/hour and 5,000 calls/day for the app, shared by all connected blogs. A single blog’s posting limits (250 posts/day, 250 images, 20 videos, 60 minutes of video) are separate. handle_errors() maps 429 and 503 to retryable errors; the HTTP layer retries up to three times with the configured delay.
  • Access tokens are short-lived. Tumblr’s token response uses expires_in: 2520 (42 minutes). offline_access is what makes the refresh token available; without it the channel dies after the first token expires. refresh_token() sends grant_type=refresh_token with the app credentials and no redirect_uri (correct for a refresh), and keeps the old refresh token when Tumblr omits a new one.
  • Refresh identity does not move the channel. refresh_token() derives the user identity again, but the engine’s refresh path only updates the token fields, not internalId or profile, so the selected blog remains the destination. Do not “fix” the channel by reconnecting the wrong blog.
  • 401/invalid_grant/invalid_token means refresh. handle_errors() returns a refresh action for those, plus Please re-authenticate your Tumblr account. A failed refresh marks the channel refresh_needed.
  • Posting is always public. state is hard-coded to "published". Tumblr’s queue/draft states are not exposed; Rustiz’s scheduler controls when the publish call happens.
  • Media is streamed, not buffered. The multipart path re-opens the source stream for every retry. This is deliberate; do not replace it with a Vec<u8> of the whole file.
  • A non-JSON 200 can look like success to axios but not here. The multipart code explicitly requires response.response to be an object. A proxy/WAF HTML page becomes Tumblr did not return a valid post response instead of an empty post id.
  • title is a heading block, not post metadata. It counts against the 4096-character block limit. Very long titles are rejected by validation, not chunked.
  • tags is a string. Sending an array (as you would for Dev.to or Hashnode) fails validation with tags must be a string. The string is not normalized; Tumblr expects comma-separated tags.
  • Only .mp4 is treated as video. .mov/.webm are misclassified as images and usually fail at image_dimensions(). Convert videos to MP4.
  • check_validity does not validate file types or sizes. It only counts images/videos. A PDF or an oversized image passes the provider check and is rejected by Tumblr.
  • Tumblr’s post URL fallback is wrong. If integration.profile is empty (for example a channel created outside the normal setup flow), the release URL becomes https://www.tumblr.com/{id}/post/{postId}, which is not the blog post URL. Always finish the blog setup.
  • No comments, no analytics. supports_comments, supports_analytics and supports_post_analytics are all false. Extra post parts are ignored by the single-post provider.

References

Twitch (twitch)

Twitch is a live-streaming platform. Rustiz does not create streams or clips: a “post” is a chat message in the broadcaster’s own channel, or a /announcement highlighted in that chat. Threads become replies to the previous chat message.

  • Rust source: crates/rustiz-providers/src/twitch.rs
  • Upstream port: twitch.provider.ts
  • Editor: Normal
  • Connection mode: oauth
  • No sandbox and no app review for ordinary chat apps.

Overview

PropertyValue
Identifiertwitch
Display nameTwitch
Connection modeoauth (authorization code grant)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; each extra entry is a chat reply (reply_parent_message_id)
Pending / async publishingNo. supports_pending = false; post() sends and returns Posted/Success
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job5
Character limit (max_length)500 characters, hard-coded
OAuth scopesuser:write:chat, user:read:chat, moderator:manage:announcements
Redirect URI{FRONTEND_URL}/integrations/social/twitch

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
TWITCH_CLIENT_IDYes—Client id from the Twitch developer console; also sent as the Client-Id header on every Helix call
TWITCH_CLIENT_SECRETYes—Client secret; used for the authorization-code and refresh-token exchanges
TWITCH_ID_BASE_URLNohttps://id.twitch.tvOAuth origin (/oauth2/authorize, /oauth2/token). Read through Config::base_url("TWITCH_ID", …).
TWITCH_BASE_URLNohttps://api.twitch.tvHelix origin (/helix/users, /helix/chat/...). Read through Config::base_url("TWITCH", …).
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI
JWT_SECRETNo direct useemptyNot read by the Twitch flow (the OAuth state row has no encrypted client info). The rest of the server uses it for other secrets.

rustiz providers --status reports twitch as configured only when both client values are non-empty. It cannot verify that the secret is current; Twitch invalidates the previous secret when you click New Secret.

Step-by-Step Developer App Setup

1. Prepare the Twitch account

  1. Sign in at https://dev.twitch.tv/console with the Twitch account that will own the app.
  2. Verify the account’s email.
  3. Enable two-factor authentication at https://www.twitch.tv/settings/security. Twitch requires 2FA to use the developer console. Refresh the console after enabling it.
  4. Open Applications → Register Your Application.

2. Register the app

FieldValue
NameAny unique name, e.g. Rustiz
OAuth Redirect URLshttps://social.example.com/integrations/social/twitch — then click Add
CategoryChoose the closest category (e.g. Chat Bot / Website)
Client typeConfidential / server-side (Rustiz stores a client secret)
CAPTCHAComplete it

After Create, open Manage and copy the Client ID. Click New Secret and copy the secret once; Twitch will not show it again.

Twitch allows multiple redirect URLs on one app. Add every deployment (production, staging, local) that will use the same client id. There is no product or API subscription to enable in the console; the app may call Helix immediately with the scopes the user grants.

3. Set the environment and restart

TWITCH_CLIENT_ID="…"
TWITCH_CLIENT_SECRET="…"

Restart rustiz serve, rustiz worker and rustiz mcp. Check:

rustiz providers --status

4. Redirect URI (exact)

Twitch::redirect_uri(None) is:

{FRONTEND_URL}/integrations/social/twitch
  • FRONTEND_URL is trimmed of trailing slashes, so register it without a trailing slash.
  • There is no redirectmeto.com wrapper for Twitch.
  • Local example: http://localhost:5000/integrations/social/twitch. Twitch accepts http://localhost redirects for development; for a public deployment use HTTPS.
  • The same URI is sent to /oauth2/authorize and /oauth2/token. Twitch requires them to match the registered value.

There is a refresh quirk: when a connection is started with a refresh query parameter, authenticate() sends redirect_uri as {FRONTEND_URL}/integrations/social/twitch?refresh=<internal-id> while generate_auth_url() still sends the URI without the query string. Twitch matches redirect URIs exactly, so a token exchange during a “reconnect/refresh” flow can fail with invalid redirect_uri. Normal CLI/MCP connections do not set refresh, so they are unaffected.

5. Scopes requested and checked

Twitch::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec![
    "user:write:chat",
    "user:read:chat",
    "moderator:manage:announcements",
];
}
ScopeUsed byWhy
user:write:chatPOST /helix/chat/messagesSend chat messages as the authenticated user
user:read:chatNothing in this providerEventSub channel.chat.message normally needs it; Rustiz never subscribes. It is an extra scope.
moderator:manage:announcementsPOST /helix/chat/announcementsSend highlighted announcements to the broadcaster’s chat

The authorize URL URL-encodes the space-joined list:

https://id.twitch.tv/oauth2/authorize?response_type=code
  &client_id=…
  &redirect_uri=…
  &scope=user%3Awrite%3Achat%20user%3Aread%3Achat%20moderator%3Amanage%3Aannouncements
  &state=…

check_scopes() is never called. The port does not read the scope array returned by the token exchange, so a user who unchecks a permission still gets a “connected” channel; the failing call appears later.

Twitch’s own guidance says an app must request only the scopes it uses, and may suspend access for over-requesting. user:read:chat is not used by Rustiz; removing it from info.scopes is safe for this provider and reduces review risk.

6. Sandbox / testing vs production

  • No sandbox. Twitch has no test-mode app. The app works against the production API as soon as it is created.
  • No app review for ordinary chat/Helix apps. The account owner can authorize the app immediately; the consent screen lists the scopes.
  • Test with your own Twitch channel: authorize the app with the broadcaster account, then send a message or an announcement. The message is visible to real viewers if the channel is live.
  • Use a separate Twitch account/channel for tests if you do not want test messages in a real chat.
  • Production has the same setup. Twitch may still suspend an app for scope misuse, spam or API terms violations; there is no pre-approval step that prevents that.

7. Token lifetime and validation

  • Authorization-code tokens include a refresh token. authenticate() stores expires_in from the token response (Twitch access tokens are typically valid for about 4 hours; the exact value is in the response).
  • refresh_token() performs the refresh grant and fetches /helix/users again, so the stored id/name/avatar stay current.
  • Twitch’s docs require third-party apps that maintain an OAuth session to call GET /oauth2/validate periodically. Rustiz does not call /validate. It relies on API 401s to trigger the refresh path.
  • A new client secret invalidates the old one; update TWITCH_CLIENT_SECRET and reconnect if refreshes start failing.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect twitch --open
  • Prints the Twitch consent URL; --open opens it.
  • With a local FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to --timeout (default 300 s).
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll rustiz channels connect-status <id> or rustiz channels list.
  • No --external-url and no --fields: Twitch is a normal OAuth provider. Passing customFields routes to connect_custom(), which encodes the object as an authorization code and fails. Do not send it.

MCP connectChannelTool

{ "provider": "twitch" }

Returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://id.twitch.tv/oauth2/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with:

{ "connectionId": "…" }

integrationSchema reports:

{ "type": "oauth", "headless": false, "requiresHuman": true, "requiresExternalUrl": false }

There is no triggerTool / channelSetupTool follow-up: the broadcaster is the authenticated user, so is_between_steps is false.

What gets stored

GET /helix/users returns the user’s id, display_name, profile_image_url and login. AuthTokenDetails stores:

  • id = Twitch user id (used as broadcaster_id and sender_id)
  • name = display name
  • username = login
  • access_token, refresh_token, expires_in
  • picture = profile image URL

If /helix/users returns an empty data array, the channel can still be saved with empty id/name because save() only requires a non-empty access token. That channel will fail every publish with a missing/invalid broadcaster id.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "messageType": {
      "type": "string",
      "enum": ["message", "announcement"],
      "description": "Publish a normal chat message or a channel announcement (default message)"
    },
    "announcementColor": {
      "type": "string",
      "enum": ["primary", "blue", "green", "orange", "purple"],
      "description": "Color of the announcement, only used when messageType is announcement (default primary)"
    }
  },
  "additionalProperties": true
}

Defaults when omitted: messageType = "message", announcementColor = "primary".

validate_settings() rules

  1. If messageType is present and not null, it must be exactly "message" or "announcement". Any other string, number or object fails with messageType must be message or announcement.
  2. If announcementColor is present and not null, it must be exactly "primary", "blue", "green", "orange" or "purple". Otherwise it fails with announcementColor must be primary, blue, green, orange or purple.
  3. A null value is accepted and falls back to the default at publish time.
  4. Extra properties are allowed.

The same settings are read for comments, so a comment can be an announcement too.

check_validity()

Not overridden. There are no pre-flight media rules.

Media requirements

Media is ignored. Twitch chat cannot attach images or video. post() and comment() never read post.media; a post with images publishes as text only, with no warning. This is a silent data loss, not an error.

Message length

  • max_length() returns 500.
  • post() and comment() also truncate to 500 chars before sending (truncate_chars).
  • The engine counts characters after HTML stripping and rejects a longer post before it reaches Twitch; the provider truncation is a second guard.
  • Twitch’s docs say the message is limited to 500 characters. Emote names are sent as plain text and Twitch converts recognized names; Rustiz does not add colons.

Chat message vs announcement

Normal message

POST {TWITCH_BASE_URL}/helix/chat/messages
Authorization: Bearer <user token>
Client-Id: <TWITCH_CLIENT_ID>
Content-Type: application/json

{
  "broadcaster_id": "<integration.internal_id>",
  "sender_id": "<integration.internal_id>",
  "message": "<truncated text>",
  "reply_parent_message_id": "<optional, comments only>"
}
  • broadcaster_id and sender_id are both the authenticated user’s id. This posts to the user’s own channel only; Rustiz cannot post to someone else’s channel.
  • Twitch requires sender_id to match the user in the access token.
  • The response is { data: [{ message_id, is_sent, drop_reason? }] }.
  • If is_sent is false, Rustiz maps it to PostStatus::Success (the Rust enum has no error state). The drop reason is discarded. A dropped message looks successful.
  • If the response has no message_id, Rustiz generates a random 10-char id.

Announcement

POST {TWITCH_BASE_URL}/helix/chat/announcements
  ?broadcaster_id=<id>&moderator_id=<id>
Authorization: Bearer <user token>
Client-Id: <TWITCH_CLIENT_ID>
Content-Type: application/json

{ "message": "<truncated text>", "color": "primary|blue|green|orange|purple" }
  • broadcaster_id and moderator_id are both the authenticated user’s id. The user must be the broadcaster or a moderator.
  • Twitch returns 204 No Content on success. The provider uses execute() (not send_json) because the retrying client would treat 204 as an error.
  • Any non-2xx is converted to bad_body with the fixed message Unknown Error.
  • Announcements do not return a message id, so Rustiz generates a random 10-char post_id.
  • Twitch rate-limits announcements to one every 2 seconds per channel. The provider sleeps 2 seconds before every post/comment, which is a rough match but not a strict scheduler.

Threads

supports_comments = true:

  • The first entry is a normal message or announcement.
  • Each later entry sets reply_parent_message_id to last_comment_id when present, otherwise to the root post_id.
  • For a normal message, this creates a Twitch chat reply.
  • For an announcement, the code takes the announcement branch and ignores reply_parent_message_id; a thread of announcements becomes independent announcements, not replies.
  • Twitch replies are only one level deep in the UI; there is no root/thread metadata.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

Implementation Notes & Gotchas

  • user:read:chat is unused. Rustiz never subscribes to EventSub or reads chat. Twitch may suspend an app that requests scopes it does not need. Remove it from info.scopes if you control the deployment and want the minimal scope set.
  • No channel:bot / user:bot. The app sends as the authenticated user, not as a bot. This is fine for posting to the user’s own channel; it cannot join another channel’s chat as a bot.
  • Refresh redirect mismatch. As described above, a reconnect started with ?refresh= builds a token-exchange redirect_uri that includes the query string while the authorize request did not. Twitch’s exact-match rule can reject it. Normal connections are unaffected.
  • No PKCE. Twitch’s authorization-code flow uses the client secret, and Rustiz sends it in the token form. That is correct for a confidential client, but it means the secret must be protected.
  • Authorization: Bearer on Helix, not OAuth. Twitch accepts both for Helix, but the code uses Bearer and always sends Client-Id.
  • Token refresh does not send scope. Twitch’s refresh grant does not require it; the new token keeps the originally granted scopes.
  • No /oauth2/validate. Twitch’s docs require third-party apps to validate tokens periodically. Rustiz waits for an API 401 instead. A token can be revoked while the channel still looks healthy in Rustiz.
  • A 401 becomes a refresh error. The generic HTTP layer classifies an unhandled 401 as RefreshToken; the engine calls refresh_token(). If the refresh token is also invalid, the channel is marked for reconnect.
  • No error classification. handle_errors() returns None; Twitch’s JSON error bodies are passed through the generic layer. send_announcement() replaces every non-2xx with Unknown Error, losing Twitch’s message.
  • is_sent: false is hidden. A message dropped by AutoMod, a slow mode, a ban or a follower-only mode is reported as Success, not an error. The drop_reason object is discarded. Check Twitch chat if a post claims success but does not appear.
  • Announcements have a random post id. post_id is make_id(10), not a Twitch identifier. The release URL is just the channel URL, so post analytics/deletion cannot target the announcement.
  • Release URL is the channel, not the message. https://twitch.tv/{login} (or provider_identifier if profile is empty). Twitch chat messages have no stable public permalink. A missing profile produces https://twitch.tv/twitch, which is wrong.
  • 2-second sleep on every send. post() and comment() sleep 2 seconds before doing anything. This throttles bursts but also adds latency. Combined with max_concurrent_job = 5, a queue can still send several messages at once; the sleep is not a global lock.
  • Rate limits. Twitch Helix uses a points bucket (commonly 800 points per minute per user token) and returns 429 with Ratelimit-* headers. Chat sends have their own limits; announcements are documented as one per 2 seconds. The generic HTTP layer retries 429/5xx up to three times with the configured delay; the 2-second sleep reduces but does not eliminate 429s.
  • Media is silently dropped. Twitch chat has no attachments. A post with images publishes as text; check_validity() does not warn.
  • max_length is 500 Unicode scalar values. Twitch’s docs say the message is limited to 500 characters. Rust counts chars, so a grapheme cluster made of several code points (many emoji, flags, skin-tone sequences) consumes more than one unit and can be truncated earlier than the Twitch UI would.
  • The app is for one user. broadcaster_id/sender_id are always the authenticated user. To post to multiple channels, connect each broadcaster account as a separate channel; Rustiz cannot target arbitrary channels with one token.
  • No analytics or mentions. Both flags are false; there are no overrides.

References

VK (vk)

VK (VKontakte) is a social network. Rustiz connects a VK user through VK ID OAuth 2.1 + PKCE and publishes to that user’s own wall, with optional photos/videos and comments. It does not publish to communities.

Important — reported VK ID/API limitation. The OAuth flow in this provider obtains a VK ID token (the vk2.a.* format). There are reports (Postiz issue #1408, April 2026, tested against the live API) that such tokens are intended for authentication and do not authorize VK API methods such as wall.post: the call fails with error_code: 1051 — "Method is not available for this profile type". The Rust port does check the VK error object and will mark the post as an error instead of silently succeeding, but it cannot change the token type. If VK still enforces this, VK channels can connect but cannot publish with this flow. See Implementation Notes.

  • Rust source: crates/rustiz-providers/src/vk.rs
  • Upstream port: vk.provider.ts
  • Editor: Normal (plain text)
  • Connection mode: oauth

Overview

PropertyValue
Identifiervk
Display nameVK
Connection modeoauth (VK ID authorization code + PKCE S256)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; comments are created with wall.createComment
Pending / async publishingNo. supports_pending = false; post() returns PostStatus::Completed
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job5 (the code comments that VK limits are per user token)
Character limit (max_length)2048
VK ID hosthttps://id.vk.ru (VK_ID_BASE_URL)
VK API hosthttps://api.vk.ru (VK_BASE_URL)
API version5.251 (hard-coded)

Server Environment Variables

required_env() returns exactly one variable:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["VK_ID"]
}
}

required_env_alternatives() is not overridden. VK_ID is the VK ID app client id, not a URL or a base URL.

VariableRequiredDefaultPurpose
VK_IDYes""VK ID application id (client_id). An empty value is rejected before the login URL is shown.
VK_ID_BASE_URLNohttps://id.vk.ruOverrides the VK ID authorization/token/userinfo host. https://id.vk.com currently redirects to id.vk.ru; set this only for tests or a proxy.
VK_BASE_URLNohttps://api.vk.ruOverrides the VK API host used for photos.*, video.save, wall.post, wall.createComment. Both api.vk.ru and api.vk.com respond; the default is the .ru host.
FRONTEND_URLNohttp://localhost:5000Public origin used to build the redirect URI. Trailing slashes are stripped.
JWT_SECRETNo""Not used by this OAuth provider (no encrypted client info/custom fields). Set it for the rest of the deployment.

There is no VK_CLIENT_SECRET, VK_SERVICE_TOKEN, or VK_SECRET variable. The VK ID app’s “protected key” and “service key” are not read by this provider.

rustiz providers --status reports VK_ID (never its value) and marks VK configured when it is non-empty.

Step-by-Step Developer App Setup

1. Create a VK ID app

Portal: https://id.vk.ru/about/business/go/. Since December 2025 the VK ID authorization service is entered through VK Business ID (https://bid.vk.com/). You need a business profile; verify it to unlock settings, keys and extended access. An unverified profile can have its apps blocked after 60 days.

  1. Sign in to the VK ID service with VK Business ID.
  2. Create/verify the business profile.
  3. Open Мои приложения → Добавить приложение.
  4. Choose the Web platform.
  5. Set the base domain (for example social.example.com).
  6. Set the trusted redirect URL (see below).
  7. Upload an icon and create the app.
  8. Copy the application ID into VK_ID.

The app page also shows a protected key (client secret) and a service key. Rustiz does not use either.

2. Add the exact redirect URI

redirect_uri() is deliberately different from most providers:

#![allow(unused)]
fn main() {
let frontend = self.ctx.config.frontend_url();
if frontend.contains("https") {
    format!("{frontend}/integrations/social/vk")
} else {
    format!("https://redirectmeto.com/{frontend}/integrations/social/vk")
}
}

So:

FRONTEND_URLRedirect URI sent to VK
https://social.example.comhttps://social.example.com/integrations/social/vk
http://localhost:5000https://redirectmeto.com/http://localhost:5000/integrations/social/vk
http://192.168.1.10:5000https://redirectmeto.com/http://192.168.1.10:5000/integrations/social/vk

There is no trailing slash. The check is a substring check (frontend.contains("https")), not a URL-scheme check; an http:// URL whose host or path contains the letters https is treated as HTTPS and is not wrapped.

Rustiz sends this authorize URL (redirect and scope are URL-encoded):

{VK_ID_BASE_URL}/authorize
  ?response_type=code
  &client_id={VK_ID}
  &code_challenge_method=S256
  &code_challenge={BASE64URL(SHA256(code_verifier))}
  &redirect_uri={urlencode(redirect URI above)}
  &state={32-char state}
  &scope={urlencode(vkid.personal_info email wall status docs photos video)}

VK’s redirect rules make the redirectmeto.com fallback impractical. VK requires the trusted redirect URL’s domain to exactly match the app’s base domain (or a .domain.com wildcard base domain), and the redirect URL is case-sensitive. You cannot own redirectmeto.com, so a local http:// FRONTEND_URL cannot be registered as-is. VK’s localhost exception is also narrow:

  • base domain localhost;
  • trusted redirect http://localhost (port 80) or https://localhost (port 443);
  • other ports are not supported.

Use a real HTTPS deployment or an HTTPS tunnel hostname as FRONTEND_URL, and register https://<host>/integrations/social/vk as both the base domain and the trusted redirect URL. Avoid the redirectmeto.com path for VK.

3. Choose the app privacy type: Public

VK ID apps are public or confidential:

TypeToken exchangeRequirement
PublicFrom the client/backend without a service key; 15,000 token requests/day per IPRustiz’s authenticate() does not send service_token
ConfidentialBackend only, IP allowlist, service_token mandatory in every token exchange/refresh; 120,000 requests/day per appNot supported by this provider

Rustiz must use a public app. A confidential app will reject the token exchange because auth_form() never includes service_token, and the backend IP would also have to be allowlisted.

4. Enable access scopes

Rustiz requests exactly this space-separated scope string:

vkid.personal_info email wall status docs photos video
ScopeUsed for
vkid.personal_infoBasic profile: first/last name, avatar, user id
emailEmail on oauth2/user_info (not read by Rustiz, but requested)
wallwall.post and wall.createComment
photosphotos.getWallUploadServer / photos.saveWallPhoto
videovideo.save
statusRequested but not used by this provider
docsRequested but not used by this provider

In the app’s Доступы section, enable these. VK distinguishes:

  • Basic access — vkid.personal_info (always), and email (optional).
  • Extended access — wall, photos, video, status, docs, and others. Extended access requires a verified business profile. Some permissions (notably wall) are described by VK as granted “in exceptional cases” through a support request to devsupport@corp.vk.com.

This is the real production gate. Without the wall permission, the authorize flow may still complete (or grant a subset), but wall.post fails. Requesting status and docs when they are not used only adds review surface; consider whether your app needs them before submitting.

check_scopes() is not called in vk.rs. Rustiz never verifies the scope set that VK actually granted; the first API method call reveals a missing permission.

5. Sandbox / testing mode

VK has no separate sandbox environment for VK ID. There is a “mark as test app” flag in the app settings, but VK documents it as a visual marker that does not change behavior.

Practical testing:

  • Use your own VK account and a test app.
  • Use a real HTTPS FRONTEND_URL; the localhost/redirectmeto.com path is not registrable under VK’s domain rules.
  • Expect the first publish to be the real test: VK ID login can succeed while wall access is still missing.
  • VK API quotas apply: 10,000 calls/month for new/unverified users and 100,000,000 calls/month after business verification (effective 7 September 2026). VK ID authorization calls are not counted against the VK API quota.
  • The VK ID public-app token endpoint allows 15,000 requests/day per IP; a confidential app allows 120,000/day per app.

6. Production app review

There is no VK App Store-style review for a VK ID app. Once enabled, any VK user can authorize it. The gates are:

  1. verified VK Business ID profile;
  2. extended access approval for wall/photos/video (and support approval for wall if VK requires it);
  3. VK API quota.

An app can be blocked if the business profile is not verified, or for policy violations. Check the app’s status in Мои приложения; a blocked app shows the reason.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect vk

Optional:

rustiz channels connect vk --open
rustiz channels connect vk --timeout 600

Do not pass --fields or --external-url:

  • --fields uses the customFields path and base64-encodes JSON as the OAuth code; VK’s authenticate() expects a real authorization code plus device id.
  • --external-url calls external_url(), which VK does not implement.

The device_id callback problem

VK’s redirect returns three query parameters:

https://social.example.com/integrations/social/vk?code=<code>&device_id=<device_id>&state=<state>

The generic Rustiz callback (crates/rustiz-server/src/oauth.rs) reads code, state and error from the query:

#![allow(unused)]
fn main() {
struct CallbackQuery {
    code: Option<String>,
    state: Option<String>,
    error: Option<String>,
}
}

vk.rs expects the device id to be packed into the code as <code>&&&&<device_id>:

#![allow(unused)]
fn main() {
fn split_code(value: &str) -> (String, String) {
    match value.split_once("&&&&") {
        Some((code, device)) => (code.to_string(), device.to_string()),
        None => (value.to_string(), String::new()),
    }
}
}

On a real VK redirect the device id is a separate query parameter, so authenticate() sends an empty device_id to POST /oauth2/auth. VK’s token exchange requires device_id, so the connection fails even though the user approved the app.

Workaround: rewrite the callback URL before reloading, packing the device id into the code value with percent-encoded ampersands:

https://social.example.com/integrations/social/vk?code=<code>%26%26%26%26<device_id>&state=<state>

The callback decodes this to code&&&&device_id, split_code() recovers both values, and the token exchange can proceed. The code is valid for 10 minutes; do this immediately.

A durable fix is to teach the server callback to accept device_id and pass it to the provider (for example as part of AuthParams.code).

MCP

Start:

{ "provider": "vk" }

Poll:

{ "connectionId": "Ab12Cd" }

connectChannelTool returns the authorize URL and connectionId. A human must open the URL, approve the app, and — with the current callback — you must apply the device_id rewrite before polling. Then poll the same connectionId and call integrationList.

No customFields, externalUrl, name or description for VK. custom_fields() is not overridden, so integrationSchema.customFields is an empty array: there are no keys or validation regexes.

No setup step

is_between_steps = false; there is no channelSetupTool step and no triggerTool function. There is no page/community picker: the channel is the authorized user and publishes to that user’s wall.

Post Settings & Media Validation

settings_schema()

The schema is empty:

#![allow(unused)]
fn main() {
json!({ "type": "object", "properties": {}, "additionalProperties": true })
}

validate_settings() is the trait default (accepts anything). VK has no provider-specific settings; the target is implied by the token (wall.post without owner_id posts on the authorized user’s own wall).

Example:

{ "__type": "vk" }

The engine adds __type automatically when missing. rustiz posts create --json post.json works; provider settings are optional.

Publish behavior

post() uses only the first PostDetails:

  1. Uploads every media item (see below), concurrently.

  2. Builds attachments as a comma-separated list of {type}{user_id}_{media_id} values, e.g. photo123_456,video123_789.

  3. Calls:

    POST {VK_BASE_URL}/method/wall.post?v=5.251&access_token=...&client_id=...
    

    with form fields:

    { "message": "<post message>", "attachments": "photo..." }
    

    The code always sends message, even when it is empty. attachments is omitted when there is no media.

  4. Reads response.post_id, and returns PostResponse::done(...): status = PostStatus::Completed, release_url = https://vk.com/feed?w=wall{user_id}_{post_id}.

No owner_id and no from_group are sent, so this is a personal wall post, not a community post. The client_id query parameter is sent although wall.post does not require it.

Comments / threads

comment():

  • uploads the comment’s media with the same upload_media() helper;
  • calls wall.createComment with message, post_id, and optional attachments;
  • reads response.comment_id;
  • returns release_url for the original post;
  • ignores last_comment_id completely, so every comment is a top-level comment, not a reply to the previous one.

post_id is the numeric wall post id (the provider’s post_id is a single id, not a comma-separated list). Because posts go to the user’s own wall, wall.createComment can resolve the post without owner_id.

Media requirements

  • No check_validity() override: any type/count is accepted by Rustiz.

  • Media is classified by filename only:

    #![allow(unused)]
    fn main() {
    let video = has_extension(&media.path, "mp4");
    }

    has_extension lowercases the path and checks for the substring .mp4. Therefore:

    • .mp4, .MP4, or .mp4?x=1 → video path;
    • .mov, .webm, .mkv, .avi, .m4v, .gif, .pdf, etc. → photo path, even though mime_for() knows some video MIME types.
  • Photos:

    1. GET /method/photos.getWallUploadServer?owner_id={user_id}&access_token=...&v=5.251
    2. POST the bytes to the returned upload_url as multipart field photo;
    3. POST /method/photos.saveWallPhoto?access_token=...&v=5.251 with photo, server, hash;
    4. collect response[0].id.
  • Videos:

    1. GET /method/video.save?access_token=...&v=5.251;
    2. POST the bytes to the returned upload_url as multipart field photo (the code uses the same field name for both types);
    3. use video.save’s response.video_id as the attachment id.
  • Likely video bug: VK’s video.save upload server normally expects the file under the multipart field video_file (the upstream issue reports video_file). The Rust port sends photo, so video uploads may not attach.

  • Uploads run concurrently via futures::future::try_join_all; one failure aborts the whole post. max_concurrent_job = 5 limits post jobs, not this per-post fan-out.

  • VK’s platform limit is 10 media per wall post. Rustiz does not enforce it; VK rejects the 11th.

  • mime_for() maps jpg/jpeg, png, gif, webp, mp4, mov, avi, mkv, webm; anything else becomes application/octet-stream.

  • No alt text, thumbnails or convert_to_jpeg support.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported. No analytics, mentions, pending posts, resolve_release_id or missing() implementation.

Implementation Notes & Gotchas

  • VK ID tokens may not be accepted by VK API methods. The flow obtains a vk2.a.* VK ID token. Postiz issue #1408 (April 2026, live API, v=5.199) reports wall.post → error_code: 1051 — "Method is not available for this profile type", while read-only methods such as users.get work. Apps created on id.vk.com/id.vk.ru cannot fall back to the legacy oauth.vk.com token endpoint (it returns 401). The Rust port correctly checks the error object (check_api_error) and will fail the post with a readable message, but it cannot make a VK ID token API-capable. If this is still enforced, VK is connectable but not publishable with this provider. Workarounds reported upstream require a legacy vk1.a.* user token (oauth.vk.com implicit flow) or a community token, neither of which this provider implements.
  • wall is an exceptional permission. VK’s wall.post documentation says the wall access right is granted in exceptional cases through devsupport@corp.vk.com. A working login is not proof that publishing is allowed.
  • Token exchange is multipart, not form-urlencoded. VK’s reference says POST /oauth2/auth must use Content-Type: application/x-www-form-urlencoded. auth_form() uses reqwest::multipart::Form, which sends multipart/form-data; the oauth2/user_info call uses the same multipart helper. If VK strictly enforces the content type, both code exchange and refresh (and userinfo) fail.
  • Token errors are not inspected. authenticate() reads access_token directly. If VK returns an {"error": ...} body with HTTP 200, the empty access token is passed to userinfo and the connection fails with the generic “The platform did not return an access token” message instead of VK’s error_description.
  • Token exchange omits state. VK lists state as a required parameter for grant_type=authorization_code; authenticate() does not send it. The authorize request does send a 32-character state, and Rustiz verifies it before calling the provider, but VK may still reject the exchange.
  • Confidential apps fail. No service_token is sent on exchange or refresh, and no backend IP allowlist is configured. Use a public app.
  • device_id is not captured by the generic callback. See the rewrite workaround above. This is the most likely cause of a “login succeeded but token exchange failed” report.
  • redirectmeto.com conflicts with VK’s base-domain rule. VK requires the redirect domain to exactly match the app’s base domain. A local http://localhost:5000 becomes a redirectmeto.com URL, which cannot be your base domain. Use HTTPS and a real host.
  • Refresh tokens rotate and invalidate the old pair. VK returns a new access + refresh token; the old pair is invalid immediately. Rustiz stores refresh_token as {refresh}&&&&{device_id} and re-splits it on refresh. If a refresh response omits refresh_token, details() stores an empty string and the next refresh fails.
  • No proactive refresh. refresh_cron is the default false; the engine refreshes reactively when check_api_error returns error 5 or the shared HTTP layer sees a 401. Access tokens last one hour.
  • Refresh request omits service_token too. Public apps are fine; confidential apps are not.
  • VK returns HTTP 200 on API errors. check_api_error() inspects all["error"] after every API call:
    • error_code 5 → ProviderError::refresh_token (re-auth/refresh);
    • 6 (too many requests per second), 9 (flood control), 29 (rate limit) → ProviderError::Other (retryable);
    • everything else → bad_body with VK’s error_msg. Because VK uses 200, the shared 401/429/500 classification does not see these errors. HTTP-level failures still go through handle_errors, which VK does not override (so a plain 401 is refreshed by the shared layer; 429/500 are retried up to 3 times / 4 attempts).
  • Only wall.post and wall.createComment are implemented. There is no wall.edit, wall.delete, community posting, scheduled post (publish_date), poll, or from_group.
  • Personal wall only. post() does not send owner_id, so it cannot post to a community even if the user is an admin. release_url assumes the post is on the user’s wall.
  • max_length is 2048, much lower than VK’s own wall post limit. Longer text is rejected by Rustiz’s validation before VK sees it.
  • Attachments assume user_id owns every medium. The code builds photo{user_id}_{id} and video{user_id}_{id}. For personal-wall photos and videos this is normally correct, but if VK returns a different owner (for example a community), the attachment string is wrong. The upstream issue flags this as a general bug.
  • username is not a handle. details() sets username = first_name.to_lowercase(), so the channel profile is a lowercased first name, not VK’s screen name. name is "{first} {last}" (a trailing space if the last name is empty).
  • The authorize URL has no prompt, provider, lang_id, or scheme. Users always see the default VK ID flow; there is no way to force consent or bypass interaction from Rustiz.
  • PKCE verifier uses rand::thread_rng, not OsRng. It is a CSPRNG-backed generator in practice, but the code does not use OsRng like the Whop provider. The verifier is 86 base64url characters and the state is 32 alphanumeric characters, both within VK’s requirements.
  • No scope check. check_scopes() is never called; a granted subset is discovered only when a method fails.
  • VK API version is pinned to 5.251. 5.251, 5.199, 5.207 and 5.300 currently respond on api.vk.ru, but VK changes method behavior between versions. A future removal of 5.251 requires a code change.

References

Whop (whop)

Whop is a marketplace/community platform. Rustiz connects a Whop user with OAuth 2.1 + PKCE and publishes forum posts to a Whop experience (forum). It can also publish comments/replies, upload images and other files through Whop’s three-step file API, and list the companies/forums the user can access.

  • Rust source: crates/rustiz-providers/src/whop.rs
  • Upstream port: whop.provider.ts
  • Editor: Markdown
  • Connection mode: oauth
  • Tool tip: “Schedule posts to forums”

Overview

PropertyValue
Identifierwhop
Display nameWhop
Connection modeoauth (authorization code + PKCE S256; public client)
Editormarkdown
Channel kindsocial
Comments / threadsYes. supports_comments = true; comments are forum posts with parent_id
Pending / async publishingNo. supports_pending is the default false; post() returns PostStatus::Success
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3 (the code comments that Whop limits are per token)
Character limit (max_length)50000
API basehttps://api.whop.com (WHOP_BASE_URL)
Web basehttps://whop.com (WHOP_WEB_BASE_URL)
OAuth endpointshttps://api.whop.com/oauth/authorize, /oauth/token, /oauth/userinfo

Server Environment Variables

required_env() returns exactly one variable:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["WHOP_CLIENT_ID"]
}
}

required_env_alternatives() is not overridden, so there are no aliases. The client secret is never read.

VariableRequiredDefaultPurpose
WHOP_CLIENT_IDYes""OAuth app id (app_...). An empty value makes /public/v1/integrations/whop/connect fail before opening a broken login page.
WHOP_BASE_URLNohttps://api.whop.comOverrides the API and OAuth host. Set it to https://sandbox-api.whop.com to use the Whop sandbox. This is an origin, not the /api/v1 URL; the provider appends /api/v1 or /oauth/.... Trailing slashes are stripped.
WHOP_WEB_BASE_URLNohttps://whop.comOverrides the web host used only to build release_url (/experiences/{experience}/{post_id}). For sandbox, https://sandbox.whop.com.
FRONTEND_URLNohttp://localhost:5000Public origin of this Rustiz deployment. The OAuth redirect URI is derived from it. Trailing slashes are stripped.
JWT_SECRETNo""Not used by the Whop OAuth flow itself (there is no encrypted clientInfo/customFields payload). Set it for the rest of the deployment.

There is no WHOP_CLIENT_SECRET or WHOP_API_KEY variable in this provider. Whop’s public-client PKCE flow does not send a secret.

rustiz providers --status reports these variable names (never values) and marks Whop configured when WHOP_CLIENT_ID is non-empty.

Step-by-Step Developer App Setup

1. Create the app

Production: https://whop.com/dashboard/developer → create/select an app.

Sandbox: https://sandbox.whop.com/dashboard/developer → create a separate app. Sandbox and production share no accounts, keys, products, webhooks or OAuth apps. A sandbox client id cannot be used against production, and vice versa.

2. Add the exact redirect URI

Rustiz builds the redirect URI in redirect_uri():

#![allow(unused)]
fn main() {
format!("{}/integrations/social/whop", self.ctx.config.frontend_url())
}

frontend_url() trims trailing slashes. Register exactly:

{FRONTEND_URL}/integrations/social/whop

Examples:

https://social.example.com/integrations/social/whop
http://localhost:5000/integrations/social/whop

There is no trailing slash after whop, no www, and no redirectmeto.com wrapping for Whop. Whop requires an exact match, including scheme, host, port and path.

Rustiz sends this authorize URL (values URL-encoded where noted):

{WHOP_BASE_URL}/oauth/authorize
  ?response_type=code
  &client_id={WHOP_CLIENT_ID}
  &redirect_uri={urlencode(FRONTEND_URL/integrations/social/whop)}
  &scope={urlencode(openid profile email forum:post:create forum:read company:basic:read)}
  &state={6-char state}
  &nonce={16-char nonce}
  &code_challenge={S256(verifier)}
  &code_challenge_method=S256

3. Copy the client id

Copy the app’s client_id (looks like app_xxxxxxxxx) into WHOP_CLIENT_ID. The client secret is shown in the dashboard but Rustiz does not use it: the authorize/token flow is the public-client PKCE flow from Whop’s OAuth guide.

4. Select scopes / permissions

Rustiz sends this exact space-separated scope string in the authorize URL:

openid profile email forum:post:create forum:read company:basic:read
ScopeUsed for
openidOIDC login; required by Whop’s userinfo endpoint
profilename, preferred_username, picture
emailemail / email_verified on userinfo (not read by Rustiz, but requested)
forum:post:createPOST /api/v1/forum_posts (posts and comments)
forum:readlisting forums (GET /api/v1/forums) and reading forum data
company:basic:readlisting companies (GET /api/v1/companies)

In the Whop dashboard, open the app’s Permissions tab and add forum:read, forum:post:create and company:basic:read. Whop requires a justification for each permission and a required/optional choice. The permissions flow is required even when testing on your own account.

check_scopes() is not called anywhere in whop.rs. Rustiz does not verify the scope set that Whop actually granted; a missing scope surfaces as a platform error on the first API call. Whop maps insufficient_scope to a “please re-authenticate” error, but refreshing a token cannot add a scope — the user must approve the new permission.

5. Sandbox / testing mode

Whop has a full sandbox:

SandboxProduction
Dashboardsandbox.whop.com/dashboard/developerwhop.com/dashboard/developer
API basehttps://sandbox-api.whop.comhttps://api.whop.com
OAuth authorizehttps://sandbox-api.whop.com/oauth/authorizehttps://api.whop.com/oauth/authorize
Web linkshttps://sandbox.whop.comhttps://whop.com

To test:

  1. Create the app in the sandbox dashboard and register the same redirect URI there.
  2. Set WHOP_CLIENT_ID to the sandbox app id.
  3. Set WHOP_BASE_URL=https://sandbox-api.whop.com and WHOP_WEB_BASE_URL=https://sandbox.whop.com.
  4. Connect a sandbox Whop account and post to a sandbox forum.

Do not mix sandbox and production values: the keys/app ids look identical but belong to isolated environments.

6. Production app review

A Whop app can be installed and used through its direct install/consent flow without being listed in the Whop App Store. App Store listing is a separate review:

  • open the app in the Developer dashboard;
  • select Publish app → Submit for review.

Whop checks that the app’s experience/dashboard view loads inside Whop, the listing has an icon, name, description, base URL, path and at least two gallery images, every permission has its own meaningful justification, and the app does not use Whop branding. A pure OAuth integration that only posts through the API does not need a listing to work for accounts that authorize it; listing is for discoverability.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect whop

Useful flags:

rustiz channels connect whop --open          # launch the login URL
rustiz channels connect whop --timeout 600   # seconds to wait (default 300)

Do not pass --fields or --external-url:

  • --fields switches to the customFields path and base64-encodes the JSON as the OAuth code; Whop’s authenticate() expects a real authorization code, so it fails.
  • --external-url calls external_url(), which Whop does not implement.

The CLI prints login url: ... on stderr (stdout is the JSON document) and, when FRONTEND_URL points at http://localhost/127.0.0.1, serves the callback in-process and waits for the channel to appear. With a public HTTPS FRONTEND_URL the URL is returned with a note that the server handles the callback.

Immediate OAuth response shape:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "Ab12Cd",
  "url": "https://api.whop.com/oauth/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

MCP

Start:

{ "provider": "whop" }

Poll:

{ "connectionId": "Ab12Cd" }

connectChannelTool returns the URL and connectionId. The user opens the URL, approves the app, and the agent polls with the same connectionId until connected: true, then calls integrationList for the channel id.

Do not pass customFields, externalUrl, name or description for Whop. custom_fields() is not overridden, so integrationSchema.customFields is an empty array: there are no keys or validation regexes.

Selecting a company and forum (not channelSetupTool)

Whop has is_between_steps = false, so there is no post-connect channelSetupTool flow and the channel is saved immediately. Rustiz does not store the chosen company/forum on the integration; both ids belong in each post’s settings.

Two helper functions are exposed through triggerTool:

FunctionParametersReturns
companies{}[{"id": "biz_...", "name": "<company title>"}]
experiences{"id": "<company id>"}[{"id": "exp_...", "name": "<forum/experience name>"}]

companies calls GET /api/v1/companies?first=50; experiences calls GET /api/v1/forums?company_id={company}&first=50. Both read only the first 50 records and both swallow errors, returning [] on any failure.

MCP examples:

{
  "integrationId": "int_...",
  "methodName": "companies",
  "dataSchema": []
}
{
  "integrationId": "int_...",
  "methodName": "experiences",
  "dataSchema": [{ "key": "id", "value": "biz_xxxxxxxxx" }]
}

The experiences parameter key is id, not company or companyId. For a forum whose nested experience object is missing, the tool falls back to the forum id for both id and name (it does not use the forum title).

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredValidation / behavior
companystringYesminLength: 1. Company id used to locate the forum. It is not sent in the create-post body.
experiencestringYesminLength: 1. Sent as experience_id to POST /api/v1/forum_posts. This is the actual forum target.
titlestringNoOptional forum post title. Omitted when empty; comments never send it.

additionalProperties: true.

Example:

{
  "__type": "whop",
  "company": "biz_xxxxxxxxxxxx",
  "experience": "exp_xxxxxxxxxxxx",
  "title": "Weekly update"
}

The simple CLI form cannot pass settings; use rustiz posts create --json post.json with a CreatePostDto (posts[].settings). MCP settings array:

{
  "settings": [
    { "key": "company", "value": "biz_xxxxxxxxxxxx" },
    { "key": "experience", "value": "exp_xxxxxxxxxxxx" },
    { "key": "title", "value": "Weekly update" }
  ]
}

validate_settings() rules

  1. company must exist and be a non-empty string.
  2. experience must exist and be a non-empty string.
  3. If title is present and not null, it must be a string. An empty string is accepted and then omitted at publish time.

Publish behavior

post() uses only the first PostDetails:

  1. Reads experience from settings; missing/empty is a hard error.
  2. Uploads every media item (see below) and collects { "id": "<file id>" }.
  3. Sends:
{
  "experience_id": "exp_...",
  "content": "<post message, Markdown>",
  "title": "<optional>",
  "attachments": [{ "id": "file_..." }]
}

company is not included. The response id becomes post_id; release_url is:

{WHOP_WEB_BASE_URL}/experiences/{experience}/{post_id}

status is PostStatus::Success (not Completed).

Comments / threads

comment():

  • reads experience from the comment’s settings (required);
  • sets parent_id to last_comment_id when supplied, otherwise to the original post_id;
  • reuses the same POST /api/v1/forum_posts endpoint;
  • never sends title;
  • returns release_url pointing at the original post, not the comment.

Because the engine passes the previous comment id as last_comment_id, a multi-part thread becomes a chain of nested replies on Whop.

Media requirements

  • No check_validity() override: Rustiz accepts any media type and count.
  • Each media item is uploaded with Whop’s file API:
    1. POST /api/v1/files with {"filename": "<last path segment>"};
    2. PUT the bytes to upload_url, copying upload_headers from the response;
    3. poll GET /api/v1/files/{id} every 5 seconds until upload_status == "ready".
  • The poll gives up after 108 attempts (about 9 minutes) with File upload timed out; upload_status == "failed" fails immediately.
  • The provider sets Content-Length itself, then applies Whop’s upload_headers; the code intends Whop’s own values (including a length) to win if they are present.
  • The PUT does not set a Content-Type; it relies on the presigned headers Whop returns.
  • Attachments are sent as { "id": ... } only; alt text and thumbnails are ignored.
  • If the create-file response has no upload_url, the file id is attached without uploading anything (likely an empty/broken attachment).
  • Whop’s own attachment count/type limits are not enforced by the provider.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported. There is no resolve_release_id, release_url override, or missing() implementation.

Implementation Notes & Gotchas

  • Public client only. The token exchange sends client_id + code_verifier and no client_secret; the refresh request sends only client_id + refresh_token. This matches Whop’s public PKCE flow. An app configured as a confidential client that requires a secret will fail at token exchange.
  • Refresh tokens rotate. Store the new refresh token from every refresh. token_details() always sets refresh_token to Some(...), even if Whop omitted it (then it is an empty string and the next refresh fails).
  • Access tokens last one hour. token_details() falls back to 3600 seconds when Whop omits expires_in or sends 0. refresh_cron = false, so there is no proactive hourly sweep; the engine refreshes reactively when a call returns 401 or an invalid_grant/insufficient_scope body.
  • company_id-scoped tokens cannot be refreshed correctly. If the authorize request used company_id, Whop requires the same company_id on refresh. refresh_token() does not send one, so refresh fails and the channel needs reconnecting. Rustiz never sends company_id itself, but a manually built authorize URL could.
  • Reconnect appends ?refresh= to the redirect URI. When a channel is reconnected with ?refresh=<internal id>, authenticate() sends redirect_uri = "<registered uri>?refresh=<id>". Whop requires an exact redirect URI match, so this token exchange can be rejected. A fresh connect (no refresh) is unaffected.
  • Scope check is absent. info.scopes is only used to build the authorize URL. There is no check_scopes() call and no post-callback validation. The first API call reveals a missing permission.
  • insufficient_scope requests a refresh, but only re-consent fixes it. The error text tells the user to re-authenticate with the required scopes.
  • Error mapping is substring-based. handle_errors() checks the body for invalid_grant, insufficient_scope, invalid_request and not_found. An HTTP 401 with no classification is also mapped to a refresh by the shared HTTP layer; a 429/500 is retried automatically (up to 3 retries / 4 attempts, with the 5-second production delay between attempts).
  • Rate limit. Whop documents 600 authenticated API requests per minute per operation and API credential. A 429 carries a retry window; the shared client retries 429 and 500 responses and also recognizes rate_limit_exceeded / Rate limit markers in a 200/4xx body.
  • Uploads bypass the retry client. The PUT to the presigned URL uses execute() directly, so a transient 429/5xx during the byte upload is not retried. The status polling uses the retrying client.
  • company is required but unused when publishing. It exists so the composer/tool can resolve the forum. Only experience reaches the API.
  • The special experience_id: "public" target is unsupported. Whop requires account_id when experience_id is public; forum_body() never sends account_id, so a literal public experience fails.
  • Only the first post is published. post() ignores posts[1..]; additional parts must be comments.
  • Comments inherit the main post’s experience. If the comment settings omit experience, the call fails even though post_id is known.
  • release_url is constructed, not verified. It uses the experience id and post id in a path; Whop’s canonical forum URL may differ. Treat it as a best-effort link.
  • Helpers silently return []. Any network/JSON error in companies or experiences is swallowed, so an empty list can mean “no access”, “bad token”, or “Whop changed the response shape”.
  • Tool ids and settings types. companies returns id as Whop sends it (normally a biz_... string). validate_settings() requires a string; if a number ever appears, stringify it before scheduling.
  • Token errors are redacted. without_request_body() replaces the error body with {} for BadBody/RefreshToken so the authorization code or refresh token is not persisted in the error record.

References

WordPress (wordpress)

WordPress is a “bring your own site” provider for self-hosted WordPress.org sites. Rustiz authenticates with HTTP Basic using a WordPress username and an Application Password, then creates posts through the site’s REST API (/wp-json/wp/v2/...). There is no OAuth app, no scope model and no app review.

  • Rust source: crates/rustiz-providers/src/wordpress.rs
  • Upstream port: wordpress.provider.ts
  • Editor: HTML
  • Connection mode: customFields

This provider is not a WordPress.com/Jetpack connector. WordPress.com uses OAuth2 against public-api.wordpress.com; Rustiz talks to https://<your-site>/wp-json/wp/v2 with Basic auth. Use a self-hosted WordPress.org site.

Overview

PropertyValue
Identifierwordpress
Display nameWordPress
Connection modecustomFields (site domain, username, Application Password)
Editorhtml
Channel kindsocial (article content)
Comments / threadsNo. supports_comments = false; only the first PostDetails is published
Pending / async publishingNo. supports_pending = false; returns PostStatus::Completed
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job8 (sites are the customer’s own servers)
Character limit (max_length)100000 characters for the post content
Inline imagesYes (inline_images = true)
APIWordPress REST API v2

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. There is no base-URL override for this provider: the site origin comes from each channel’s credential payload (the base64 JSON integration token).

VariableRequiredDefaultPurpose
FRONTEND_URLNo (but recommended)http://localhost:5000Used only by media_content_type() to recognize Rustiz’s own uploads ({FRONTEND_URL}/uploads/...) and avoid an extra HEAD request. It does not control the WordPress URL.
ALLOW_PRIVATE_NETWORKNofalseGlobal SSRF switch. A staging/local WordPress on localhost or a private IP is blocked unless this is true (or 1). Only enable it for trusted test deployments.
UPLOAD_DIRECTORYNounsetGlobal media store path. Local main_image.path values are read from here (or fetched from a URL).

Unlike Listmonk and Lemmy, WordPress does not read the encrypted custom_instance_details copy. Its credentials are carried in the channel’s integration token as base64 JSON, so JWT_SECRET is not required for this provider.

Step-by-Step Developer App Setup

There is no app to register. The setup is on the WordPress site.

  1. Confirm the site is WordPress 5.6 or newer and is served over HTTPS. Application Passwords are available in core since 5.6 and are intended for HTTPS; many hosts hide them on plain HTTP.

  2. Confirm the REST API is reachable:

    curl -i https://blog.example.com/wp-json/
    

    A JSON index means the REST API is enabled. A 404, HTML login page, or a security-plugin block means the provider cannot work until that is fixed.

  3. Create or choose a WordPress user with enough capability:

    • publish_posts to publish posts immediately;
    • edit_posts for drafts;
    • upload_files if a featured image will be uploaded;
    • edit_pages / the custom post type’s capabilities for pages or custom types. A least-privilege Author can publish their own posts and upload media; a Contributor can only submit pending posts.
  4. As that user, open Users → Profile (or Users → All Users → Edit) and find Application Passwords.

  5. Enter a name such as Rustiz, click Add New Application Password, and copy the generated password. It is shown only once. WordPress displays it in groups with spaces; spaces are allowed and stripped during validation.

  6. Verify the credentials before connecting:

    curl -s -u 'rustiz:xxxx xxxx xxxx xxxx xxxx xxxx' \
      https://blog.example.com/wp-json/wp/v2/users/me
    

    Rustiz calls exactly this endpoint during authenticate. A 200 with id, name, avatar_urls is success; 401/403 means the password, username, Authorization header, or a security plugin is the problem.

Products and permissions

  • No WordPress products or APIs to enable; the REST API is core.
  • No OAuth scopes. The Application Password inherits the user’s capabilities.
  • rest_cannot_create from WordPress is mapped to The connect user has insufficient permissions to create posts.
  • Security plugins/WAFs commonly strip the Authorization header or block /wp-json/. Rustiz’s authentication errors call this out explicitly.

Sandbox / testing mode

WordPress has no platform sandbox. Use a staging clone of the site, or use the draft, pending or private statuses so a test does not appear publicly.

  • A draft is visible only in wp-admin.
  • pending is visible to editors for review.
  • private is visible to logged-in users with permission.
  • Rustiz does not support WordPress’s future status; scheduling is handled by Rustiz before it calls WordPress.
  • For a local/staging site on a private address, set ALLOW_PRIVATE_NETWORK=true on the Rustiz server (SSRF protection is on by default).

Production app review

There is no app review. The same Application Password works immediately. Site-level plugins, hosting WAF rules and user capabilities are the effective access controls.

Connecting a Channel (CLI & MCP)

The credential payload has three keys:

KeyTypeValidation regexNotes
domaintext/^https?:\/\/(?:www\.)?[\w\-]+(\.[\w\-]+)+([\/?#][^\s]*)?$/Site URL including scheme. A subdirectory install is allowed (for example https://example.com/blog). Do not add a trailing slash — see the bug below.
usernametext/.+/WordPress login name, not the display name or email.
passwordpassword/.+/Application Password. The UI hint says Application password, create in User->Profile.

CLI

{
  "domain": "https://blog.example.com",
  "username": "rustiz",
  "password": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
rustiz channels connect wordpress --fields wordpress.json

printf '%s' '{"domain":"https://blog.example.com","username":"rustiz","password":"xxxx xxxx xxxx xxxx xxxx xxxx"}' \
  | rustiz channels connect wordpress --fields -

Immediate result:

{ "connected": true, "pending": false, "name": "Rustiz" }

The displayed name is the WordPress user’s name, not the site title.

MCP

{
  "provider": "wordpress",
  "customFields": {
    "domain": "https://blog.example.com",
    "username": "rustiz",
    "password": "xxxx xxxx xxxx xxxx xxxx xxxx"
  }
}

No follow-up step. Call integrationList for the channel id. Use --fields / customFields; --external-url / externalUrl is not the credential path for this provider.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
postTypesnone[{"id": "<rest_base>", "name": "<label>"}]Calls /wp-json/wp/v2/types; filters out keys containing wp_ or nav_, and attachment. id is the REST base (posts, pages, etc.), which is what settings.type expects.
categoriesListnone[{"id": <number>, "name": "..."}]Calls /wp-json/wp/v2/categories?per_page=100. Only the first 100 categories are returned.
tagsListnone[{"id": <number>, "name": "..."}]Calls /wp-json/wp/v2/tags?per_page=100. Only the first 100 tags are returned.

MCP examples:

{ "integrationId": "int_...", "methodName": "postTypes" }
{ "integrationId": "int_...", "methodName": "categoriesList" }

All three have an empty input_schema / dataSchema. wp_get() does not go through the publishing error classifier, so an error response that is valid JSON can be returned as if it were data; check the shape before using it.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
titlestringYesminLength: 2; checked. Also used to build the slug.
main_imageobjectNoFeatured image. Properties: id (media library id, string), path (public URL or upload path, string), alt (string), thumbnail (URL string). id and path are required by the schema.
typestringYesREST base of the post type: posts, pages, or a custom type’s rest_base from postTypes. Not the type slug post/page.
categoriesarray of integersNoCategory ids. Must be JSON numbers; strings/floats fail validation.
tagsarray of integersNoTag ids. Must be JSON numbers.
statusstring enumNopublish, draft, pending, private. Missing/empty defaults to publish. WordPress’s future is not accepted.

Example object form:

{
  "title": "Salt & Pepper",
  "main_image": {
    "id": "55",
    "path": "https://blog.example.com/wp-content/uploads/cover.png",
    "alt": "A salt and pepper shaker"
  },
  "type": "posts",
  "categories": [3, 4],
  "tags": [9],
  "status": "draft"
}

MCP settings array:

{
  "settings": [
    { "key": "title", "value": "Salt & Pepper" },
    { "key": "type", "value": "posts" },
    { "key": "categories", "value": [3, 4] },
    { "key": "status", "value": "draft" }
  ]
}

The engine adds "__type": "wordpress" automatically if missing.

validate_settings() rules

  1. title must exist, be a string and have at least 2 characters.
  2. type must exist and be a string.
  3. main_image, if present and not null:
    • must be an object;
    • id must be a string;
    • path must be a string;
    • alt, if present, must be a string;
    • thumbnail, if present and non-empty, must be an http(s) URL with a host.
  4. categories and tags, if present and not null, must be arrays whose items are all integers (i64 or u64). Floats and numeric strings fail.
  5. status, if present and not null, must be one of publish, draft, pending, private.
  6. There is no extension check, size check or RESTRICT_UPLOAD_DOMAINS gate for main_image.path.

Media requirements and upload behavior

  • main_image is uploaded to /wp-json/wp/v2/media as raw bytes with:
    • Content-Disposition: attachment; filename="<last path segment>";
    • a Content-Type derived from a HEAD request when the path is remote and not a Rustiz own upload, otherwise from the file extension;
    • User-Agent: Rustiz/1.0.
  • The returned media id becomes featured_media. If the id is null, an empty string or numeric 0, it is omitted.
  • The post’s media / attachments array is ignored. Only main_image is uploaded as the featured image.
  • inline_images = true: Rustiz’s HTML sanitizer keeps <img> tags in the post content and allows the img tag. Images embedded in the content are the way to put pictures in the body; they are not uploaded by Rustiz.
  • alt and thumbnail in main_image are accepted but not applied to the WordPress media item.
  • Because slugify() is strict ASCII, a title written entirely in a non-Latin script can produce an empty slug. WordPress then falls back to its own slug behavior.

check_validity() is the trait default (Ok(None)). WordPress has no provider-specific media-count, dimension or file-type rule; the only media handling is the main_image upload and the inline <img> preservation described above.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Trailing slash bug: auth normalizes, publishing does not. authenticate() trims whitespace and trailing slashes into a local domain variable for the /users/me request: let domain = creds.domain.trim().trim_end_matches('/'). But it stores params.code unchanged as the access token, and post() calls decode_credentials() again and uses creds.domain raw. A user who pastes https://blog.example.com/ authenticates successfully and then publishes to https://blog.example.com//wp-json/wp/v2/... (double slash). Always strip the trailing slash in the connect payload.
  • The channel id embeds the raw domain. authenticate() returns id = format!("{}_{}", creds.domain, user_id), so the same site with and without a trailing slash produces different internal ids. Combined with the bug above, reconnect cleanly if you change the domain format.
  • type is the REST base, not the WordPress type slug. postTypes returns rest_base values such as posts and pages. Sending "type": "post" (as some Postiz examples do) calls /wp-json/wp/v2/post, which is a 404. Use posts.
  • Categories and tags are ids, not names. Both are numeric arrays. The provider’s number_list() also accepts numeric strings at publish time, but validate_settings() rejects strings before that, so always send numbers.
  • Attachments are silently dropped. A post with attachments but no main_image creates a text-only WordPress post. Put body images in the HTML content instead.
  • WordPress status future is unsupported. WordPress can schedule natively, but the Rustiz enum only allows publish, draft, pending, private. Let Rustiz schedule the job and send publish when it runs.
  • No base URL override. Unlike other providers, there is no WORDPRESS_BASE_URL. The domain is per channel, which is correct for a multi-tenant deployment.
  • Application Passwords are per user and revocable. The connect payload is stored as the channel’s integration token (base64 JSON); the server also writes an encrypted copy to custom_instance_details, but the WordPress provider does not read that copy. Base64 is encoding, not encryption: treat the Integration.token column as a secret. Rotating the Application Password requires reconnecting every WordPress channel.
  • JWT_SECRET is not needed for WordPress. Unlike Listmonk and Lemmy, this provider decodes credentials from access_token, not from custom_instance_details. A changed JWT_SECRET does not break existing WordPress channels.
  • Errors from the tools are not classified. wp_get() uses execute() + serde_json::from_str; a WordPress error object is valid JSON and is returned as a successful Value. postTypes then iterates it as a map. If a tool returns strange entries, check the HTTP status and the WordPress error body directly.
  • Media content type can be wrong for signed URLs. For remote URLs, Rustiz sends a HEAD request and trusts Content-Type; if the HEAD fails, it falls back to the file extension. A signed CDN URL with no extension and a failing HEAD becomes application/octet-stream, which WordPress may reject.
  • Security plugins are the usual failure mode. Wordfence and similar plugins can block /wp-json/, strip Authorization, or disable Application Passwords via wp_is_application_passwords_available. The authentication error messages in Rustiz are written for exactly these cases.
  • No refresh. Credentials never expire; refresh_token() returns an empty token with expires_in: 0. A 401/403 requires reconnecting with a valid Application Password.

References

X (x)

X (formerly Twitter) is the only provider in this set that authenticates with OAuth 1.0a instead of OAuth 2.0. Every request — the OAuth handshake, media upload and post creation — is signed with HMAC-SHA1 using the app’s consumer key/secret and the user’s access token/secret. The API calls themselves are X API v2 endpoints (/2/...), with the v2 chunked media upload flow for images and video.

  • Rust source: crates/rustiz-providers/src/x.rs
  • Upstream port: x.provider.ts (upstream uses twitter-api-v2 + sharp)
  • Editor: HTML (articles keep rich text; regular posts are flattened to text)
  • Connection mode: oauth (OAuth 1.0a, a human opens a login URL)
  • Related provider: none

Overview

PropertyValue
Identifierx
Display nameX
Connection modeoauth (OAuth 1.0a 3-legged flow)
Editorhtml
Channel kindsocial
Comments / threadsYes. supports_comments = true; each extra entry is posted as a reply with reply.in_reply_to_tweet_id
Pending / async publishingYes. supports_pending = true; media is uploaded and polled, then the post is created behind an arm → confirm → publish handshake
AnalyticsYes. supports_analytics = true; may be disabled with DISABLE_X_ANALYTICS
Post analyticsYes. supports_post_analytics = true
MentionsYes. supports_mentions = true; GET /2/users/by/username/{handle}
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job10
Character limit (max_length)280 normally, 4,000 when the stored Verified additional setting is true, 100,000 for post_type = "article"
API basehttps://api.x.com (X_API_BASE_URL)
Tool tip“You will be logged in into your current account, if you would like a different account, change it first on X”

Important: the Rust port uses OAuth 1.0a because that single credential can call both v2 post endpoints and the v2 media endpoints. Upstream Postiz also uses OAuth 1.0a; its media path historically needed the legacy v1.1 upload endpoint, while Rustiz uses the newer v2 chunked upload.

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["X_API_KEY", "X_API_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
X_API_KEYYes—Consumer key (“API Key”) from the X app’s Keys and tokens tab. Signs every OAuth 1.0a request.
X_API_SECRETYes—Consumer secret (“API Key Secret”) from the same tab. Used as the HMAC-SHA1 signing key together with the token secret.
X_API_BASE_URLNohttps://api.x.comOrigin for the API and the OAuth 1.0a legs. Read via base_url("X_API", ...); trailing slash stripped. This is the real API override.
X_URLNoFRONTEND_URLOnly overrides the base of the OAuth callback ({X_URL}/integrations/social/x). It does not change the API base in the Rust port, despite what the Postiz docs say.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the callback URI when X_URL is unset. Trailing slashes are stripped.
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state (code_verifier, state) at rest.
DISABLE_X_ANALYTICSNounsetWhen true, analytics() and post_analytics() return an empty list without calling X.
STRIP_LINKS_FROM_X_POSTSNounsetWhen true, URLs and bare domains are removed from post and comment text before publishing.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Article inline images and the article cover are rejected unless their URL contains this domain.

rustiz providers --status only checks that X_API_KEY and X_API_SECRET are non-empty. It cannot tell whether the keys belong to the app whose callback URI is registered, or whether the app has write permission.

Step-by-Step Developer App Setup

1. Create the developer account and app

  1. Sign in at https://console.x.com (the old developer.twitter.com portal redirects here). Accept the Developer Agreement and complete the profile.
  2. Create a Project and an App. The app gets an API Key and API Key Secret (the consumer credentials Rustiz needs).
  3. Open the app’s User authentication settings:
    • Enable OAuth 1.0a.
    • Set App permissions to Read and Write. Read-only tokens fail at POST /2/tweets; the OAuth handshake itself still succeeds.
    • Add the exact Callback URI (see step 3).
    • A website URL is required by the form; any valid HTTPS URL works.
  4. Open Keys and tokens and copy API Key and API Key Secret. The access token/secret shown there belong to the app owner and are not used by Rustiz; each channel gets its own token through the 3-legged flow.

2. Legacy portal note: app type

Older X portals asked for a Type of App. Postiz’s guide warns that choosing Web App, Automated App or Bot makes OAuth 1.0a fail with error code 32 and that Native App must be selected. If your console still shows that selector, choose Native App. Current consoles replaced it with the OAuth 1.0a toggle and callback URI fields.

3. Redirect URI (exact)

X::callback_url() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/x", base.trim_end_matches('/'))
}

where base is X_URL when set, otherwise Config::frontend_url(). Examples:

https://social.example.com/integrations/social/x   # production
http://localhost:5000/integrations/social/x        # Docker / rustiz serve default
http://localhost:4200/integrations/social/x        # upstream Postiz dev UI

Register the value exactly as above. X_URL is unusual: setting it to an X-compatible API origin (as the Postiz documentation suggests) changes the callback URI, not the API origin. To point Rustiz at a compatible API, set X_API_BASE_URL instead.

X accepts http://localhost callback URIs for development. There is no redirectmeto.com wrapper in this provider; if X rejects a plain-HTTP local callback, use an HTTPS tunnel and set X_URL to that public origin.

4. OAuth 1.0a handshake performed by Rustiz

  1. POST {api}/oauth/request_token with oauth_callback={callback} and x_auth_access_type=write, signed with the consumer key/secret.

  2. The temporary token/secret pair is packed into code_verifier as "{oauth_token}:{oauth_token_secret}"; state is the temporary token.

  3. The human is sent to:

    {api}/oauth/authenticate?oauth_token={token}&force_login=false
    
  4. On return, POST {api}/oauth/access_token with oauth_token and oauth_verifier, signed with the temporary token secret.

  5. GET /2/users/me?user.fields=username,verified,verified_type,profile_image_url,name verifies the token and stores the profile. The final stored token is "{access_token}:{access_token_secret}".

There are no scopes in OAuth 1.0a and check_scopes() is never called. The effective permission is the app-level Read and Write setting. An app set to Read-only connects successfully and fails only when posting.

5. Free vs paid access (2026)

X retired the old self-serve tiers. As of 2026:

PlanStatusPosting allowance / cost
Legacy FreeClosed to new developers on 6 Feb 2026Was 500 posts/month, 100 reads/month; active users were moved to pay-per-use with a $10 voucher
Legacy BasicRetired for new signups (migrated from June 2026)Was $200/month, 3,000 posts/month per user, 15,000 reads/month
Legacy ProRetired for new signups (migrated from September 2026)Was $5,000/month, 300,000 posts/month, 1,000,000 reads/month
Pay-per-use (current)Default for new developersPrepaid credits: $0.015 per post created, $0.20 if the post contains a URL, $0.005 per post read, capped at 3M post reads/month
Public UtilityApplication onlyFree scaled access for approved public-interest apps

Budget for link posts: the URL surcharge is more than ten times a plain post. Media upload calls themselves are not the expensive part; post creation is.

6. Sandbox / testing vs production

X has no sandbox environment and no app-review step comparable to Meta. The app is live as soon as it has credentials and credits:

  • Use a dedicated test X account and a separate app/project for development.
  • A post created through the API is immediately public. There is no “development mode” that hides it.
  • Rate limits apply even in testing. POST /2/tweets is limited to 100 requests per 15 minutes per user and 10,000 per 24 hours per app.
  • If you run out of credits, posting fails; set a budget/alert in the Developer Console.
  • X_URL/X_API_BASE_URL can point at a mock server for tests, but the OAuth 1.0a signature must still match the real consumer credentials.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect x
rustiz channels connect x --open
rustiz channels connect x --timeout 600
  • The login URL is printed to stderr (login url: ...); --open launches the browser.
  • With a local FRONTEND_URL (http://localhost:… / http://127.0.0.1:…) the CLI serves the callback on the frontend port itself and waits up to --timeout seconds (default 300) for the channel to appear.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the running server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.
  • --external-url is meaningless for X and should not be passed.
  • --fields is for credential providers; X needs no fields.

Behind the scenes the CLI calls POST /public/v1/integrations/x/connect, which reaches connect::start() → oauth::begin() → X::generate_auth_url().

MCP connectChannelTool

{
  "provider": "x"
}

The tool returns:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://api.x.com/oauth/authenticate?oauth_token=…&force_login=false",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Then poll:

{ "connectionId": "…" }

connectChannelTool with an MCP client that advertises URL elicitation sends the login URL through elicitation/create instead of returning it as text.

integrationSchema reports:

{
  "connection": {
    "type": "oauth",
    "headless": false,
    "requiresHuman": true,
    "requiresExternalUrl": false
  }
}

There is no follow-up triggerTool / channelSetupTool call: is_between_steps is false and the channel is stored directly after the callback.

What not to send

  • customFields is for credential providers. In connect.rs, any customFields object routes to connect_custom(), which base64-encodes it as the OAuth code; X then treats it as an authorization code and fails. Never send customFields to x.
  • externalUrl is ignored by X; do not pass it.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "post_type": {
      "type": "string",
      "enum": ["post", "article"],
      "description": "post = a regular short post (plain text). article = a long-form X article: write the post content as HTML (p, h1, h2, h3, ul, ol, li, strong, u, a) - the formatting is kept in the article"
    },
    "article_title": {
      "type": "string",
      "minLength": 1,
      "description": "The title of the article, required when post_type is article"
    },
    "article_status": {
      "type": "string",
      "enum": ["draft", "published"],
      "description": "draft = save the article as a draft on X, published = publish it right away, required when post_type is article"
    },
    "article_cover": {
      "type": "object",
      "description": "Optional cover image for the article, only used when post_type is article",
      "properties": {
        "id": { "type": "string", "description": "Media id of the uploaded picture" },
        "path": { "type": "string", "description": "Public URL of the uploaded picture" },
        "alt": { "type": "string", "description": "Alt text of the cover" },
        "thumbnail": { "type": "string", "description": "Thumbnail URL" }
      },
      "required": ["id", "path"]
    },
    "who_can_reply_post": {
      "type": "string",
      "enum": ["everyone", "following", "mentionedUsers", "subscribers", "verified"],
      "description": "Who can reply to the post; required for regular posts, ignored for articles"
    },
    "community": {
      "type": "string",
      "pattern": "^(https://x\\.com/i/communities/\\d+)?$",
      "description": "Optional X community URL, for example https://x.com/i/communities/1493446837214187523"
    },
    "made_with_ai": {
      "type": "boolean",
      "description": "Mark the post as made with AI"
    },
    "paid_partnership": {
      "type": "boolean",
      "description": "Mark the post as a paid partnership"
    }
  },
  "additionalProperties": false
}
FieldTypeRequired in practiceBehaviour
post_type"post" / "article"No; defaults to postarticle switches to the Articles endpoints and HTML content
article_titlestring, non-emptyYes when post_type=articleSent as the draft title
article_status"draft" / "published"Yes when post_type=articledraft stops after POST /2/articles/draft; published calls the publish endpoint
article_coverobjectNo{id, path, alt?, thumbnail?}; uploaded separately as a JPEG tweet_image
who_can_reply_postenumYes for regular postsOmitted from the request when everyone; sent as reply_settings otherwise
communityX community URLNo; empty string allowedAdds share_with_followers: true and community_id (the last path segment)
made_with_aibooleanNo; defaults falseAlways sent to POST /2/tweets (and to comments)
paid_partnershipbooleanNo; defaults falseAlways sent to POST /2/tweets (and to comments)

additionalProperties: false is advisory: the exported schema tells UI/MCP clients not to send unknown keys, but the Rust validate_settings() only checks the fields listed above and ignores extra keys.

validate_settings()

  • settings must be a JSON object.
  • post_type missing is treated as "post"; any other value is an error.
  • community, when non-null, must match ^https://x\.com/i/communities/\d+$. An empty string matches because the whole group is optional.
  • For post_type != "article", who_can_reply_post must be exactly one of everyone, following, mentionedUsers, subscribers, verified. A missing value is an error even though the JSON schema does not mark it required.
  • For article: article_title must be a non-empty string and article_status must be draft or published.
  • article_cover, when non-null:
    • must be an object;
    • id must be a string (an empty string is accepted — it is typed as a string by the DTO);
    • path must be non-empty and end in .png, .jpg, .jpeg, .gif, .webp or .mp4;
    • if RESTRICT_UPLOAD_DOMAINS is set, path must contain that domain;
    • alt must be a string when present;
    • thumbnail, when a non-empty string, must parse as http/https.
  • made_with_ai and paid_partnership must be boolean or string when present (the validator accepts numeric-looking strings; as_bool then reads "true" case-insensitively).

check_validity()

Only articles are pre-checked:

  1. If post_type != "article" → no checks.
  2. If any media path contains .mp4 → “X articles only support images”.
  3. If article_status != "published" and posts.len() > 1 → “A draft article cannot have thread replies, remove them or publish the article”.

A draft article has no public post id to reply to, so the thread check is deliberate.

Media requirements and upload behaviour

MediaRule in the Rust port
ImageAny path not containing .mp4. Resized to width 1000 px with Lanczos3 and re-encoded. Uploaded as tweet_image (non-article) or as JPEG tweet_image (article)
Animated GIFPath contains .gif; every frame is resized and re-encoded, uploaded as tweet_gif
VideoOnly paths containing .mp4 are treated as video. .mov is treated as an image and fails at decode. Uploaded as tweet_video
Article inline images<img src> with an http(s) scheme (case-insensitive) and allowed by RESTRICT_UPLOAD_DOMAINS; uploaded as JPEG and inserted as Draft.js atomic blocks
Article coverUploaded as JPEG tweet_image and sent as cover_media
Alt textIgnored for X. The media metadata endpoint (POST /2/media/metadata) is never called

Platform limits enforced by X, not by Rustiz:

  • Up to 4 photos, 1 GIF, or 1 video per post.
  • Image ≤ 5 MB, GIF ≤ 15 MB; video duration 20 minutes / 8 GB by default, 125 minutes / 16 GB for Premium accounts. The cap follows the posting account’s Premium status, not the API plan.
  • Chunked upload: initialize → append → finalize → STATUS. Rustiz uses 1 MiB segments (the API allows up to 5 MB, server max 8 MB). Segments are numbered from 0 and sent as multipart media with Content-Type: application/octet-stream.
  • finalize may return processing_info; Rustiz polls GET /2/media/upload?command=STATUS&media_id=….

Articles

Articles are a v2 feature added to X in June 2026:

  1. POST /2/articles/draft with {title, content_state, cover_media?}. content_state is Draft.js: blocks plus entities. Rustiz converts the HTML subset p, h1, h2, h3, ul, ol, li, strong, u, a, img into blocks:
    • h1 → header-one, h2/h3 → header-two (header-three is downgraded because X’s draft endpoint returned 503 for it);
    • ul/ol → unordered-list-item / ordered-list-item;
    • strong → bold inline style; a → link entity;
    • img → atomic block with an image entity.
  2. If article_status = "draft", the returned article id is stored and the release URL is https://x.com/i/articles.
  3. If article_status = "published", POST /2/articles/{id}/publish is called and data.post_id becomes the published post id. The release URL uses https://twitter.com/{profile}/status/{post_id}.

Articles require an X Premium subscription on the posting account. X returns a “not authorized to create or publish articles” error which Rustiz maps to that message.

Plugs

MethodKindFieldsEffect
autoRepostPost@Plug (run_plug)likesAmountReads GET /2/tweets/{id}/liking_users; when result_count >= likesAmount, waits 2 s and reposts via POST /2/users/{internal_id}/retweets
autoPlugPost@Plug (run_plug)likesAmount, postWhen the like threshold is met, replies to the post with the stripped text
repostPostUsers@PostPlug (run_post_plug)—Reads /2/users/me for the target integration and reposts; errors are swallowed

likesAmount is read as a string or number (plug_text), then parsed as i64; a missing/invalid value becomes 0, so the plug fires on the first check.

Implementation Notes & Gotchas

  • OAuth 1.0a, not OAuth 2.0. Do not configure OAuth 2.0 client credentials and expect them to work: X_API_KEY/X_API_SECRET are the consumer key and secret, and the stored token is access_token:access_token_secret. The provider’s tests verify the signature against X’s documented worked example (Ls93hJiZbQ3akF3HF3x1Bz8/zU4=).
  • X_URL is the callback origin, not the API origin. The Postiz docs say X_URL overrides the API base. In x.rs, callback_url() reads X_URL, while api() reads X_API_BASE_URL. Setting X_URL to a self-hosted X-compatible API therefore breaks the callback instead of redirecting API traffic. Use X_URL only to point the OAuth callback at an HTTPS tunnel when FRONTEND_URL is a local address.
  • No token refresh. OAuth 1.0a tokens are long-lived; authenticate() stores expires_in = 999_999_999 (about 31.7 years) and an empty refresh_token. refresh_token() returns an empty access token with expires_in: 0, so the engine marks the channel refreshNeeded and asks the user to reconnect if X ever rejects the token. There is no automatic repair.
  • Token secrets are stored in one string. access_token is "{token}:{secret}"; anything that splits on : assumes exactly one colon. The token itself is URL-safe, so this is stable, but do not add prefixes.
  • Arm → confirm → publish. post_pending() uploads media and stores {message, settings, mediaIds, coverMediaId, inlineMediaIds, processingIds, attempting, confirmed}. check_post_status() performs one media STATUS read per processing id. finalize_post() first flips attempting=true, confirmed=false and returns Pending; the next check witnesses it and sets confirmed=true; only then is POST /2/tweets sent. If a run dies after the confirm but before the result is recorded, the next check refuses to retry with “X may have already published this post” rather than risk a duplicate.
  • Known duplicate risk window. The handshake prevents re-creating a post when a previous attempt was confirmed but its result was lost. It cannot detect a create that X accepted and whose HTTP response was lost before confirmed was persisted; in that narrow window a retry can duplicate.
  • Comment processing waits longer than post processing. post() has an 8-minute overall budget and sleeps 20 s between checks. comment() waits for each processing media with default_wait_ms = 1000 and a 7-minute max.
  • Article image waits have no timeout. upload_bytes() polls processing_info with max_wait_ms = None. If X keeps returning a check_after_secs value and never reaches succeeded/failed, that call can loop indefinitely. Video uploads avoid this by returning still_processing to the durable workflow instead of waiting inside upload_video().
  • Media is uploaded sequentially. upload_media_entries() loops posts and media one by one on purpose, so a large carousel does not hold every file in memory at once. With max_concurrent_job = 10, a media-heavy queue can be slower than the concurrency number suggests.
  • Image re-encoding quirk. resize_gif() always returns GIF bytes, even for JPEG/PNG/WebP inputs (it encodes a single frame). upload_image() then declares the original MIME type (image/jpeg, image/png, …). If X rejects an otherwise valid image with an unsupported-media error, this mismatch is the first thing to inspect. Article images take the separate JPEG path and are unaffected.
  • Article cover accepts .mp4 but cannot process it. validate_settings() allows .mp4 in article_cover.path, yet the upload path always calls resize_jpeg() on the bytes. An MP4 cover fails at image decode; use an image.
  • article_cover.alt and .thumbnail are validated but unused. The draft request only sends cover_media: {media_category, media_id}; neither the alt text nor the thumbnail URL reaches X. article_cover.id is also ignored: the provider uploads the file at article_cover.path itself and uses the returned media id.
  • h3 is intentionally flattened to header-two. The comment in the code says X’s draft endpoint returns 503 for header-three. If X fixes it, the mapping can be restored.
  • Polls are not implemented. PostDetails.poll is ignored entirely. X’s POST /2/tweets accepts a poll object with 2–4 options and duration_minutes 5–10080, but Rustiz never sends it. A poll configured in the UI is silently dropped.
  • Quote posts are not implemented. quote_tweet_id is never sent; X also requires Enterprise for quote posting on self-serve plans.
  • Media alt text is not implemented. MediaContent.alt is ignored for X.
  • max_length Premium detection is weak. The engine looks for an additional setting {title: "Verified", value: true} captured at connect time from GET /2/users/me’s verified boolean. The code requests verified_type but never reads it. A Premium account that is not “verified” with a blue check is capped at 280 even though X would accept long-form posts.
  • Article cap is arbitrary. max_length returns 100_000 for articles. X’s actual article body limit is not documented in the API reference; the engine only uses this for local counting.
  • URL counting differs from X. Rustiz counts characters after HTML stripping using its own logic. X counts every URL as 23 characters and weights emoji/CJK as 2. A post that passes local validation can still be rejected with a length error.
  • Community posting. community must be an https://x.com/i/communities/… URL; the provider sends community_id as the last path segment and share_with_followers: true. A numeric id alone is rejected by validate_settings().
  • reply_settings charset. X accepts following, mentionedUsers, subscribers, verified; everyone is the absence of the field. The Rust enum matches exactly.
  • Analytics are best-effort. Both analytics methods swallow errors and return [] (a warning is logged). analytics() caps the lookback at 100 days (days.min(100)), fetches up to 100 tweets per page, then a second /2/tweets?ids=… call for public_metrics. It emits only two points per metric (start and end) with percentage_change: 5.0 hard-coded, so it is a summary, not a real time series.
  • Post analytics are exact but sparse. GET /2/tweets/{id} with public_metrics returns one point per metric dated today. Missing metrics are skipped.
  • Mentions resolve a single account. mention() looks up /2/users/by/username/{query} and returns at most one entry. A 404/any error returns an empty list, not an error.
  • STRIP_LINKS_FROM_X_POSTS also affects comments and plugs. The stripper removes http(s) URLs and bare domains from a hard-coded TLD list, collapses spaces and trims. Email addresses are preserved by the regex.
  • Error mapping. handle_errors() matches many X error bodies: Unsupported Authentication and "title":"Unauthorized" → refresh; Service Unavailable / Too Many Requests → retry; duplicate content, cashtag limit, 4-item media limit, suspended account, locked account, crypto addresses, invalid media ids, article permission, empty thread entry → bad body with actionable text. A 429 that does not contain the literal string Too Many Requests falls through to the generic HTTP retry policy.
  • Rate limits. POST /2/tweets is 100/15 min per user and 10,000/24 h per app. POST /2/media/upload/initialize|append|finalize is 1,875/15 min per user. GET /2/tweets is 5,000/15 min per user. The Analytics lookback of 100 days with 100 tweets/page can consume a meaningful number of reads.
  • Pay-per-use billing. The app-level cap is 3M post reads/month; writes are billed per request, with a $0.20 surcharge for posts containing a URL. A retry after a failed post is a second billed create unless X rejected it before creating.
  • Version drift. The module pins no API version because X v2 is versionless in the URL. The META_GRAPH_API_VERSION constant in facebook.rs is unrelated to X.

References

YouTube (youtube)

youtube uploads one video per post through the YouTube Data API v3 resumable upload protocol, then optionally sets a custom thumbnail. Publishing is resumable and restart-safe: post_pending only opens an upload session, check_post_status asks the session how many bytes Google has (308 + Range), and finalize_post streams the missing ranges in 8 MiB chunks (a multiple of YouTube’s required 256 KiB) for up to four minutes per call.

The provider is is_between_steps = true: the OAuth login authenticates a Google account, then channelSetupTool selects one of that account’s YouTube channels.

  • Rust source: crates/rustiz-providers/src/youtube.rs
  • Upstream port: youtube.provider.ts
  • Editor: Normal
  • Connection mode: oauth + a mandatory channel-selection step

Testing-mode trap. A Google Cloud OAuth consent screen in Testing mode accepts up to 100 test users, but every authorization — including its refresh token — expires after 7 days. A channel connected this way stops publishing a week later. Publish the app / complete verification for anything long-lived. See Sandbox / testing mode.

Overview

PropertyValue
Identifieryoutube
Display nameYouTube
Connection modeoauth, then channelSetupTool (channel selection)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingYes. supports_pending = true; resumable upload session with check_post_status / finalize_post
AnalyticsYes. supports_analytics = true; YouTube Analytics API v2, channel level
Post analyticsYes. supports_post_analytics = true; Data API videos.list statistics
MentionsNo. supports_mentions = false
is_between_stepstrue — the OAuth channel is a placeholder until a YouTube channel is selected
convert_to_jpegfalse
max_concurrent_job200 (a semaphore, not a quota; the real limit is YouTube’s daily upload bucket)
Character limit (max_length)5000 characters (the video description)
APIYouTube Data API v3, YouTube Analytics API v2, Google OAuth 2.0
OAuth scopeshttps://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../youtube, .../youtube.force-ssl, .../youtube.readonly, .../youtube.upload, .../youtubepartner, .../yt-analytics.readonly
Token lifetimeGoogle access token ~1 h; refresh token until revoked (7 days in Testing mode)

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
&["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
YOUTUBE_CLIENT_IDYesnoneGoogle Cloud OAuth 2.0 Web application client id.
YOUTUBE_CLIENT_SECRETYesnoneGoogle Cloud OAuth client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI. Trailing / stripped.
YOUTUBE_BASE_URLNohttps://www.googleapis.comData API and oauth2/v2/userinfo origin.
YOUTUBE_OAUTH_BASE_URLNohttps://oauth2.googleapis.comToken endpoint (/token) and /tokeninfo.
YOUTUBE_AUTH_BASE_URLNohttps://accounts.google.comAuthorization endpoint (/o/oauth2/v2/auth).
YOUTUBE_ANALYTICS_BASE_URLNohttps://youtubeanalytics.googleapis.comAnalytics reports (/v2/reports).

required_env_alternatives() is not overridden. Google My Business can fall back to these same credentials; see gmb.md.

rustiz providers --status | jq '.[] | select(.identifier=="youtube")'

Step-by-Step Developer App Setup

1. Create/select a Google Cloud project

  1. Open https://console.cloud.google.com/projectselector2/apis/credentials and accept the terms.
  2. Create project (or reuse the one you use for Google My Business — the Postiz docs explicitly say the same project/credentials can serve both).

2. Enable the APIs

Under APIs & Services → Enable APIs and Services, enable:

APIWhy
YouTube Data API v3channels.list, videos.insert, videos.list, thumbnails.set
YouTube Analytics APIv2/reports channel analytics
YouTube Reporting APIOptional; bulk reports. Postiz’s guide enables it, the Rust provider does not call it.

Open Google Auth Platform → Audience (formerly OAuth consent screen):

  1. User type: External (unless the project belongs to a Google Workspace org and only internal users need it).
  2. Fill in app name, support email, developer contact, logo, authorized domains.
  3. Add the scopes the provider requests (see below). Google classifies the YouTube scopes as sensitive; the broad youtube and youtubepartner scopes get extra scrutiny.
  4. Under Test users, add every Google account that will connect while the app is in Testing. Add the account that owns the target YouTube channel (or a Brand Account manager).
  5. Keep the publishing status on Testing for development, or Publish app for production.

4. Create the OAuth client

  1. Google Auth Platform → Clients → Create client.

  2. Application type: Web application.

  3. Name it (e.g. Rustiz).

  4. Authorized redirect URIs — add exactly:

    {FRONTEND_URL}/integrations/social/youtube
    

    Example: https://social.example.com/integrations/social/youtube. Google requires scheme/host/port/path to match; http://localhost:5000/... is allowed for local development. No trailing slash unless FRONTEND_URL itself produced one (it is trimmed).

  5. Create, then copy the Client ID and Client secret. Google shows the secret only once; download or store it immediately.

5. Set the environment

YOUTUBE_CLIENT_ID="....apps.googleusercontent.com"
YOUTUBE_CLIENT_SECRET="..."
FRONTEND_URL="https://social.example.com"

Restart Rustiz, check rustiz providers --status, then connect and select a channel.

The same OAuth client can be reused for GMB by adding {FRONTEND_URL}/integrations/social/gmb to the authorized redirect URIs, or by setting separate GOOGLE_GMB_CLIENT_ID / GOOGLE_GMB_CLIENT_SECRET.

Exact OAuth scopes and checks

#![allow(unused)]
fn main() {
vec![
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/youtube",
    "https://www.googleapis.com/auth/youtube.force-ssl",
    "https://www.googleapis.com/auth/youtube.readonly",
    "https://www.googleapis.com/auth/youtube.upload",
    "https://www.googleapis.com/auth/youtubepartner",
    "https://www.googleapis.com/auth/yt-analytics.readonly",
]
}

The authorization URL requests them space-separated, with access_type=offline and prompt=consent (so a refresh token is returned even on reconnect).

After the code exchange, token_scopes() calls POST {YOUTUBE_OAUTH_BASE_URL}/tokeninfo with the bearer token and reads either scope (space-separated string) or scopes (array). check_scopes then requires all eight. A user who deselects youtube.upload or yt-analytics.readonly on the granular consent screen produces not_enough_scopes and the channel is refused.

What each scope is used for:

ScopeUse
userinfo.profile, userinfo.emailGET /oauth2/v2/userinfo (name, picture, account id)
youtubeBroad channel management; channels.list, metadata
youtube.force-sslWrite operations over HTTPS
youtube.readonlychannels.list for page selection / reconnect
youtube.uploadvideos.insert resumable upload
youtubepartnerContent-owner features; not needed for ordinary uploads
yt-analytics.readonlyAnalytics reports

youtubepartner and the full youtube scope are the hardest to justify in verification. They are hard-coded in scopes(); trimming them requires a code change.

Redirect URI details

{FRONTEND_URL}/integrations/social/youtube
  • Config::frontend_url() removes trailing slashes before concatenation.
  • No redirectmeto.com: the literal value is used at authorize time and token exchange. Google requires an exact match, including scheme, host, port and path.
  • Google allows http://localhost redirects (and http://127.0.0.1) as a development exception; production must be HTTPS.

Sandbox / testing mode

Google has no separate sandbox host; the OAuth consent screen’s Testing publishing status is the sandbox.

  • Up to 100 test users may be listed on the consent screen. Being listed consumes the project’s test-user quota once added.
  • Every test user sees an “unverified app” warning before consenting.
  • Authorizations and refresh tokens expire seven days after consent. This is the single most important limitation for a scheduler: connect a channel in Testing mode and it will stop publishing after a week. The exception is an app that requests only openid / email / profile — Rustiz requests much more, so the exception does not apply.
  • A Brand Account can authorize if a listed test user manages it.
  • The YouTube APIs themselves work in Testing mode, so you can validate the full upload/thumbnail/analytics flow before verification.

Production app review

  1. Publish the app (Google Auth Platform → Audience → Publish app). New users can then authorize, but they still see the unverified warning until verification completes.
  2. OAuth verification is mandatory for sensitive/restricted scopes. Prepare:
    • a homepage that explains the app and its YouTube use;
    • a privacy policy on the same verified domain;
    • a demo video showing the OAuth grant and the upload flow;
    • a written justification for every scope, especially youtubepartner and the full youtube scope.
  3. YouTube API Services audit / quota extension is separate. The default quota is:
    • 100 videos.insert calls per day (its own bucket);
    • 100 search.list calls per day (unused by this provider);
    • 10,000 units/day shared by everything else (channels.list 1, videos.list 1, thumbnails.set 50). To exceed the default, submit the YouTube API Services — Audit and Quota Extension Form.
  4. Custom thumbnails require a phone-verified YouTube channel. If the channel is not verified, the video publishes and only the thumbnail fails (PublishedWithError, with the video id/URL preserved).

Connecting a Channel (CLI & MCP)

CLI

# 1. OAuth login. Creates an in-between-steps placeholder channel.
rustiz channels connect youtube --open

# Remote server: poll the login.
rustiz channels connect-status <connectionId>

# 2. The CLI has no channel-selection command; finish with MCP
#    channelSetupTool, then list the channel.
rustiz channels list

Do not pass --fields (it switches to the custom-credentials branch) or --external-url (unsupported).

The CLI’s in-process callback server only runs when FRONTEND_URL is http://localhost/http://127.0.0.1; otherwise the public rustiz serve handles the callback.

MCP

Step 1 — start OAuth:

{ "provider": "youtube" }

Returns connectionId + url (or a URL elicitation). A human opens it.

Step 2 — poll:

{ "connectionId": "abc123" }

The channel exists but has inBetweenSteps = true and cannot publish. Its internal_id at this point is the Google account id from userinfo, not a YouTube channel id.

Step 3 — list the channels the account can post as:

{
  "integrationId": "int_...",
  "methodName": "pages",
  "dataSchema": []
}

pages calls:

GET /youtube/v3/channels?part=snippet,contentDetails,statistics&mine=true

and returns:

[
  {
    "id": "UCxxxxxxxxxxxxxxxxxxxxxx",
    "name": "My Channel",
    "picture": { "data": { "url": "https://yt3.example/avatar.jpg" } },
    "username": "@mychannel",
    "subscriberCount": "1234"
  }
]

pages() logs and returns [] on any API error (including a token missing youtube.readonly). An empty list is not necessarily “no channels”.

Step 4 — select the channel:

{
  "integrationId": "int_...",
  "data": { "id": "UCxxxxxxxxxxxxxxxxxxxxxx" }
}

fetch_page_information reads data["id"], calls channels.list?id=<id> (with part=snippet,contentDetails,statistics), and returns the channel’s title, custom URL and default thumbnail. channelSetupTool writes those into the integration, clears inBetweenSteps, and returns { "success": true, "name": "My Channel" }.

Connecting the same channel twice fails with That page is already connected as another channel.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
pagesnonearray of {id, name, picture, username, subscriberCount}mine=true; silently [] on error.

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredBehavior
titlestring (2–100)Yessnippet.title. Measured in UTF-16 code units, matching YouTube/JS. No default.
typeenum public, private, unlistedYesstatus.privacyStatus. Sent verbatim.
selfDeclaredMadeForKidsenum yes, noNoCOPPA self-declaration. "yes" → true, anything else → false. Defaults to false when omitted.
thumbnailobject {id, path, alt?, thumbnail?}NoCustom thumbnail to set after the upload. id and path are required strings; path may be a local file or URL. alt is validated as a string but never sent. thumbnail is accepted by the schema and ignored.
tagsarray of {value, label}Nolabel is what gets sent as snippet.tags; value is validated and ignored. Combined tag length is capped at 500.

additionalProperties: true. There is no description setting: the post’s message becomes snippet.description. There is no categoryId, defaultLanguage, localized, recordingDetails or publishAt — Rustiz’s own scheduler decides when the API is called.

validate_settings() rules

  • Settings must be an object.
  • title is required and must be a string; its UTF-16 length must be 2..=100. Errors: title must be at least 2 characters long, title must be at most 100 characters long.
  • type must be public, private or unlisted.
  • selfDeclaredMadeForKids, if present, must be exactly yes or no.
  • thumbnail, if present and non-null, must be an object with string id and string path; alt, if present, must be a string.
  • tags, if present and non-null, must be an array of objects; each needs a string value and a string label.
  • Tag length: for every tag, add the label’s UTF-16 length, plus 2 if the label contains whitespace. If the total exceeds 500, fail with "The maximum allowed is 500 characters in total for all tags." (This mirrors YouTube’s rule that spaces are encoded as %20.)

check_validity() rules

  • Exactly one media item is required: posts[0].len() == 1, else "You need one media".
  • The path must contain mp4 (case-sensitive substring check), else "Item must be a video".
  • Comment posts are not supported (supports_comments = false), so only posts[0] matters.
  • No duration, codec, resolution, fps or file-size validation. YouTube’s limits (12 h / 128 GB for verified accounts, shorter for unverified ones; 256 KiB chunk alignment) are left to the API.

Media and publish behavior

Start (post_pending)

  1. Take the first media path and determine its size:

    • http(s) URL → HEAD with Accept-Encoding: identity, read Content-Length (must be > 0);
    • local path → tokio::fs::metadata.
  2. Build the video resource:

    {
      "snippet": { "title": "...", "description": "...", "tags": ["..."] },
      "status": { "privacyStatus": "public", "selfDeclaredMadeForKids": false }
    }
    
  3. POST {YOUTUBE_BASE_URL}/upload/youtube/v3/videos?uploadType=resumable&part=id,snippet,status&notifySubscribers=true with Content-Type: application/json; charset=UTF-8, X-Upload-Content-Type: video/mp4 and X-Upload-Content-Length: <size>.

  4. Read the Location header. Missing it → "Could not start the video upload, please try again".

  5. Return pending with: uploadUri, videoSize, path, uploadedBytes: 0, thumbnail, videoId: null, postDbId.

No bytes are sent in this phase; nothing exists on the channel yet.

Probe (check_post_status)

PUT <uploadUri> with the bearer token and Content-Range: bytes */<size>:

ResponseMeaning
200 / 201 + JSON {id}Video created. If no thumbnail was requested → Completed with watch?v=<id>. If a thumbnail is pending → Ready with videoId.
308 Permanent RedirectIncomplete. Range: bytes=0-N → uploadedBytes = N + 1; Ready.
429 / 5xxTransient; ProviderError::other so the workflow retries the probe, not the publish.
401 or a body-classified refresh errorRefreshToken.
404 / 410"The upload session expired before the video was uploaded, please post again".
otherBadBody with the classified message.
network errorPending (keep polling).

Finalize (finalize_post)

  1. If videoId is unknown, probe again.
  2. While uploadedBytes < videoSize:
    • stop and return Pending if this call has spent 4 minutes (the next call resumes from the session offset);
    • build an 8 MiB body: HTTP URL → GET with Range: bytes=start-end (must be 206 Partial Content), local file → seek + read that range;
    • PUT <uploadUri> with Content-Type: video/mp4, Content-Length and Content-Range: bytes start-end/size;
    • 308 with a Range header → advance and continue; 308 without a range → Pending (never guess the offset);
    • 200/201 → parse {id} and stop;
    • 429/5xx → transient error; 401 → RefreshToken; other → BadBody.
  3. If all bytes were sent but no id was confirmed → Pending.
  4. If a thumbnail was requested, call POST /upload/youtube/v3/thumbnails/set?videoId=<id>&uploadType=media with the streamed image body.
    • If the thumbnail call fails with BadBody and postDbId is present, return ProviderError::PublishedWithError { release_id: videoId, release_url, ... } — the video is already live and must not be re-uploaded.
    • Any other error (e.g. token refresh) is returned as-is.
  5. Completed with watch?v=<videoId>.

Blocking post(): calls finalize_post in a loop with a 8 min - 4 min budget and errors with "The video upload took too long, please try a smaller video" if it cannot finish. The engine’s durable path can take longer across calls.

Thumbnail rules

  • settings.thumbnail.path wins; otherwise media.thumbnail (the generated video thumbnail from the engine) is used.
  • JPEG or PNG; YouTube’s limit is 2 MB. The provider does not validate the size or format before sending.
  • The channel must be phone-verified. If not, you get imageFormatUnsupported / youtube.thumbnail-style errors and the video is still published.

Plugs

Neither run_plug() nor run_post_plug() is overridden. Both use the trait defaults and return Unsupported for every method. YouTube has no auto-repost or auto-comment plug in this port.

Implementation Notes & Gotchas

  • internal_id is a Google account id until setup finishes. AuthTokenDetails.id is userinfo.id, and the channel name/picture are the Google account’s. A placeholder channel would try to upload to a non-channel and fail. Always run channelSetupTool with {"id": "UC..."}.
  • refresh_token() returns the Google account, not the channel. It calls userinfo and returns the account id/name/picture. The engine only stores the access token and expiry, so the channel’s internal_id (the YouTube channel id) is preserved; do not use the refresh result’s id as a channel id.
  • pages() hides errors. It logs and returns [], so an expired token or a missing youtube.readonly looks exactly like “this account has no channels”. Check server logs before assuming the account has none.
  • All eight scopes are mandatory. Google’s granular consent lets a user uncheck individual scopes. check_scopes then refuses the connection. There is no partial-scope mode.
  • youtubepartner is a partner-only scope. Requesting it (and the full youtube scope) makes OAuth verification harder and may be rejected for an ordinary scheduler. It is hard-coded; if verification is blocked, the provider needs a code change to drop it.
  • Testing-mode refresh tokens die after 7 days. This is a Google policy, not a bug in the port. A channel that worked last week and now returns invalid_grant / RefreshToken is the classic symptom. Publish the app and complete verification for production use.
  • No categoryId is sent. YouTube usually applies a default category, but if an upload is rejected with invalidCategoryId, this is the missing field. The same applies to defaultLanguage/localized metadata, which the port does not support.
  • notifySubscribers=true is hard-coded. Every upload notifies subscribers; there is no setting to suppress it.
  • prompt=consent forces a refresh token on every authorization. This is deliberate and also means a reconnected channel replaces its old refresh token; Google may return the same token or a new one.
  • Refresh does not rotate blindly. refresh_token() stores the old refresh token when Google omits a new one, which is the normal case. If the response contains a new one, it is stored.
  • tokeninfo is called with POST + bearer, not a query parameter. Google accepts both; the port uses POST. If you proxy Google OAuth, allow POST /tokeninfo.
  • Channel identity is re-checked on reconnect. reconnect() lists channels?part=id&mine=true and requires the requested channel id to be present; otherwise "Channel not found". It then refetches the channel metadata. This path only runs when root_internal_id differs from internal_id, which normal OAuth channels do not set.
  • Upload sessions are ephemeral. A session that returns 404/410 is gone; the provider fails the post with “post again” rather than retrying a dead session. A 308 without a Range header also returns Pending instead of guessing, which can loop until the workflow times out — safer than corrupting the video.
  • 8 MiB chunks are intentional. YouTube requires all chunks except the last to be a multiple of 256 KiB; 8 MiB satisfies that and keeps each batch short.
  • Remote media must support HTTP Range. chunk_stream requires 206 Partial Content. A storage backend/CDN that ignores Range (returns 200 with the whole file) fails with "The media storage did not return the requested byte range".
  • Thumbnail failures preserve the published video. PublishedWithError carries the video id and URL so the engine records the release even though the thumbnail failed. Do not retry the whole post; set the thumbnail manually in YouTube Studio.
  • Analytics swallows errors. analytics() returns [] on any failure (including a missing yt-analytics.readonly). post_analytics() surfaces RefreshToken but returns [] for everything else. Empty charts are not proof that the channel has no views.
  • Analytics metric set is fixed. Channel reports request views, estimatedMinutesWatched, averageViewDuration, averageViewPercentage, subscribersGained, likes, subscribersLost with dimensions=day, sorted by day. The emitted series are Estimated Minutes Watched, Average View Duration, Average View Percentage, Subscribers Gained, Subscribers Lost and Likes — views is queried but never emitted (an upstream omission). Post analytics emits Views, Likes, Comments, Favorites from videos.list.
  • Quota is the real concurrency limit. max_concurrent_job = 200 only sizes a semaphore. The default videos.insert bucket is 100/day, and each thumbnails.set costs 50 units from the 10,000-unit shared quota. A burst of posts can exhaust the day’s uploads even with the semaphore free.
  • invalid_rapt has a dedicated message. refresh_error_message() detects Google Workspace session-control errors and tells the admin to mark Rustiz as a trusted app / enable “Exempt trusted apps”. Other refresh failures use the generic cause.
  • The provider never posts comments. supports_comments = false; YouTube comments and replies are out of scope even though youtube.force-ssl would permit them.

References

Rustiz (Rust, agent-first)

A headless social media scheduler for AI agents: one binary, one SQLite file and no GUI. It is a Rust port of Postiz v1.47.0 (commit e424fb5), licensed AGPL-3.0 like the original. The original copyright notice is preserved in LICENSE. If you run a modified version as a network service, you must offer its source to its users.

Status: no provider has been tested against a real platform. All tests use mock servers, so expect fixes when you connect real accounts. See Porting status.

Measured

Release build on an Apple Silicon Mac, with an empty database. Throughput is 2,976 integrationList calls over MCP at 32 concurrent clients with rate limits turned off, and the Python test client may be the limit.

BinaryReadyRAM idleRAM after loadThroughput
16.9 MB117 ms19.0 MB24.4 MBabout 18,900 requests/s

Run

Self-hosted, on your own machine:

rustiz init                     # creates ~/.rustiz: secret, database and API key
rustiz providers --status       # which platforms are ready, and what each one needs

rustiz init prints the MCP config to paste into your agent. After that, rustiz mcp needs no arguments, and it receives the platform’s login redirect itself. Running init again never replaces the secret or creates a second organization.

On a server, set your public URL and put TLS in front, because the server speaks plain HTTP:

FRONTEND_URL=https://rustiz.example rustiz serve

Environment variables override ~/.rustiz/config.env; --config or RUSTIZ_CONFIG picks another file. Back up JWT_SECRET with the database: stored channel credentials can’t be read without it.

Each OAuth platform needs its own developer app, with credentials such as X_API_KEY or REDDIT_CLIENT_ID and a redirect URI of FRONTEND_URL/integrations/social/<provider>. They are the same variables Postiz uses. Some platforms may refuse a plain http://localhost redirect, in which case that platform needs a public HTTPS FRONTEND_URL.

Email is optional: without EMAIL_PROVIDER, nothing is sent. Set UPLOAD_DIRECTORY for local media, or STORAGE_PROVIDER=cloudflare|s3 for object storage. Video thumbnails need an ffmpeg binary, which the Docker image does not include.

CommandPurpose
rustiz initFirst-time setup
rustiz serveHTTP API, MCP and the scheduler (--no-worker for HTTP only)
rustiz workerThe scheduler alone
rustiz mcpMCP over stdio for a local agent, with the scheduler and login callback in-process
rustiz posts|channels|media|analytics|notifications|find-slotCLI for agents
rustiz org create|list|rotate-keyOrganizations and API keys; rotating revokes OAuth tokens
rustiz providers [--status]List platforms and their readiness

CLI

Every command prints one JSON document on stdout. Run rustiz --help for the full list.

rustiz channels connect reddit --open
rustiz posts create --channel <id> --content "Hello" --date 2030-01-01T09:00:00Z --type schedule
rustiz posts update <id> --content "Fixed typo"
rustiz posts reschedule <id> --date 2030-01-02T09:00:00Z
rustiz posts delete <id>

Without RUSTIZ_URL the CLI works on the local database and needs no server, but posts only publish while rustiz serve, worker or mcp is running. With RUSTIZ_URL and RUSTIZ_API_KEY it talks to a remote server. Exit codes: 1 runtime, 2 bad request, 3 auth, 4 not found, 5 post is publishing.

Connect an agent

Remote: https://your-host/mcp, with either Authorization: Bearer <api key> or OAuth. Clients that support OAuth discover it from the 401 response. The approval page asks for the API key, and it is the only form the server renders.

Local: paste the config rustiz init prints.

Set MCP_ALLOWED_HOSTS if clients reach the server on a host other than FRONTEND_URL.

Editing posts

PUT /public/v1/posts/{id}, PUT /public/v1/posts/{id}/date, updatePostTool and the CLI share one code path. A post keeps its id. While a job is publishing the post, changes are refused with a 409, so nothing is posted twice. Published posts can’t be edited, because platforms keep the original; republish with a date publishes one again. An ERROR post may already be live, so check the platform before retrying with publish. Deleting a post here does not remove it from a platform.

Rate limits

RouteLimit per hourSetting
POST on /public/v1/posts90 per organizationAPI_LIMIT
/mcp and other /public/v1 routes10,000 per organizationREQUEST_LIMIT
POST /oauth/authorize, /oauth/register30 per client addressfixed
POST /oauth/token120 per client addressfixed

0 turns a setting off. Counts are per process. X-Forwarded-For is ignored unless TRUSTED_PROXY_HOPS says how many proxies append to it; set that only if the server can’t be reached except through those proxies.

MCP behaviour

The server uses the official rmcp SDK and implements spec 2026-07-28. Older clients that use initialize work on the same endpoint.

  • Typical flow: providersStatusTool, connectChannelTool, integrationSchema, validatePostTool, schedulePostTool, postStatusTool.
  • Safe resends: an identical schedulePostTool call within 10 minutes, or one with the same idempotencyKey within 24 hours, returns the first result.
  • Tasks: type: "now" returns a durable task handle to clients that declare the Tasks extension. Others get a normal result and poll postStatusTool.
  • Connecting accounts: OAuth returns a login link for a human. Credential providers take customFields. Telegram, Moltbook and Farcaster return what the human must do, then the agent polls with connectionId. Channels marked inBetweenSteps are finished with triggerTool and channelSetupTool.
  • Live updates: subscriptions/listen on rustiz://posts/<id> or rustiz://groups/<id> sends a hint when a post’s state changes. It polls the database every 2 seconds (MCP_SUBSCRIPTIONS_POLL_MS), so it also sees a separate rustiz worker. Quick changes between polls collapse into the last one, and deletions are not reported. At most 4 streams per organization (MCP_SUBSCRIPTIONS_MAX_STREAMS).

Not in this project

Generating media is out of scope: agents make it with other tools and upload it. The cloud parts of a hosted product (signup, teams, billing, password emails, a dashboard) belong in a separate layer on top of the REST API.

Porting tracker

Port of Postiz at commit e424fb5 (v1.47.0). The original copyright notice is preserved in LICENSE. Status: ✅ ported, tested against mocks · 🟡 partial · ⬜ not done · ➖ removed by design

Nothing here has been tested against a real platform, email service or storage bucket. Most provider code was machine-ported from the TypeScript and checked by its tests. X, LinkedIn, Instagram, Facebook, Threads, YouTube, TikTok, Reddit, Pinterest, Bluesky, Mastodon, Discord, Slack and Telegram also had a machine-run line-by-line comparison with upstream, not a human review. The other providers had spot checks only.

Architecture

PostizThis port
NestJS + Next.js + orchestrator✅ one binary
Temporal + Postgres + Elasticsearch✅ jobs table and in-process worker
Postgres (Prisma)✅ SQLite in WAL mode; same table and column names, no data migration tool
Redis✅ OAuth state is in SQLite; request limits are in-process and per server process
@mastra/mcp✅ rmcp, spec 2026-07-28 and older
Mastra / LangChain / CopilotKit agents➖ agents bring their own LLM
Next.js frontend, calendar, visual editor➖ no GUI
Sentry➖ tracing logs

Providers: 35 of 36

All 35 are ✅: bluesky, dev.to, discord, dribbble, facebook, farcaster (wrapcast), gmb, hashnode, instagram, instagram-standalone, kick, lemmy, linkedin, linkedin-page, listmonk, mastodon, mastodon-custom, medium, mewe, moltbook, nostr, pinterest, reddit, slack, telegram, threads, tiktok, tiktok-business, tumblr, twitch, vk, whop, wordpress, x, youtube.

➖ skool: it only works through the Chrome extension, which was removed.

Known provider differences:

  • image replaces sharp, and a hand-built PDF replaces pdfkit, so output bytes differ. X and LinkedIn uploads need checking against the real APIs.
  • X articles use a small HTML parser instead of parse5.
  • Rust’s regex has no lookaround, so a few patterns were rewritten as equivalents.
  • Farcaster doesn’t validate BIP-39 words. Its Neynar endpoints come from public docs because the SDK source wasn’t available.
  • Analytics dates use UTC rather than server-local time.

Engine

WorkflowStatus
Publishing, threads, pending media, token refresh, duplicate protection, repeats✅
Missing posts and token refresh sweeps✅
Plugs (global and internal)✅ dispatch tested at provider level
RSS autopost✅ posts feed items as they are; LLM rewrite and image generation removed by design
Streak, digest and transactional email✅ Resend tested against a mock; SMTP/SES are config-tested only
Media✅ JPEG conversion works on local and object storage (mock store in tests); thumbnails need ffmpeg
Short links (Dub, Short.io, Kutt, LinkDrip)✅ Short.io stats read up to 10 pages

API and MCP

AreaStatus
Public API /public/v1, including analytics, webhooks, tags, sets, signatures, customers, posting times✅
MCP: 31 tools covering channels, posts, media, analytics, management, plugs, autopost✅
CLI for agents over the public API✅ channels connect not run against a real login
Edit and reschedule posts, media delete, mention lookup✅
Headless connections for every provider (Telegram, Moltbook, Farcaster included)✅ mock servers only
Provider readiness (providers/status, early refusal when credentials are missing)✅
MCP OAuth: PKCE, Client ID Metadata Documents, DCR for older clients✅
MCP Tasks extension, polling✅ type: "now" only
MCP URL elicitation for login links🟡 implemented, no test with a real client
MCP subscriptions/listen✅ polls the database; slow-client drop is untested
Storage: local, S3, R2🟡 S3/R2 use a mock store in tests
Docker image⬜ Dockerfile exists, not built in this port

Removed by design

Users, login, teams and invites (the API key identifies an organization), Stripe billing and credits, admin, marketplace, the Chrome extension, the Node SDK, all media generation (image, video, slides, clipping, HeyGen, ReelFarm, Seedance), HubSpot, and upstream’s LLM draft splitting (/posts/separate-posts). Agents bring their own LLM and media tools.

Deliberate behaviour changes

  • A stray job for an already-published post finishes silently; Postiz marks the post ERROR.
  • Unknown publish errors wait 5 seconds before retrying.
  • Request bodies are redacted before being stored in Errors.
  • The server refuses to start without JWT_SECRET; rustiz init generates it.
  • Only creating posts over REST uses API_LIMIT, as upstream. /mcp and other API routes get a separate REQUEST_LIMIT, and OAuth endpoints have per-address limits.
  • Media delete keeps the stored file, as upstream does.
  • Importing data from an existing Postiz is not offered: hosted users’ account tokens stay on Postiz’s servers, so they would reconnect anyway.
  • /mcp/{key} is gone: API keys only travel in headers.
  • previewUrl is gone from schedulePostTool results, since there is no preview page.

Benchmarks

The numbers quoted on the Rustiz website, and how they were measured. Rerun them on your own hardware before relying on them.

Rustiz (measured)

Measured on 2026-11-08 on an Apple M4 Mac (macOS, arm64), from commit 3102e77, with the release profile (lto = "fat", codegen-units = 1, strip = true, panic = "abort"), an empty database, and the load client on the same machine.

MetricResultHow
Binary size16.9 MiB (17,721,744 bytes)ls -l target/release/rustiz after cargo build --release --locked -p rustiz
Startup97–128 ms to the first /health → ok (7 cold starts, median 125 ms)A script starts rustiz serve and polls /health every millisecond; this includes the polling overhead
RAM, idle18.8–19.5 MB RSSps -o rss 2 s after startup
RAM, after load25.8 MB RSSps -o rss right after the throughput runs below
Throughput~53,500 req/s (32 clients), ~58,700 req/s (64 clients), 0 failuresab -k -c 32 -n 40000 -H "Authorization: Bearer <key>" http://127.0.0.1:5077/public/v1/integrations, with REQUEST_LIMIT=0 API_LIMIT=0

What this measures: an authenticated API call that reads the database (an empty channel list), with keep-alive, on loopback. It does not measure publishing, which is bound by the platforms’ APIs and rate limits, or MCP calls, which carry more JSON-RPC overhead. An earlier run in the project README measured MCP integrationList calls with a Python client at about 18,900 req/s, and that client may have been the bottleneck.

The binary is not fully static. On macOS it links the system’s libSystem, Security and CoreFoundation, and nothing else needs installing.

Reproduce

cargo build --release --locked -p rustiz
export RUSTIZ_CONFIG=/tmp/rzbench/config.env PORT=5077 FRONTEND_URL=http://127.0.0.1:5077
target/release/rustiz init --dir /tmp/rzbench
REQUEST_LIMIT=0 API_LIMIT=0 target/release/rustiz serve &
KEY=$(grep RUSTIZ_API_KEY /tmp/rzbench/config.env | cut -d= -f2)
ab -k -c 32 -n 40000 -H "Authorization: Bearer $KEY" http://127.0.0.1:5077/public/v1/integrations
ps -o rss= -p $!

Postiz (not measured)

The Postiz figures on the website are estimates, not measurements:

ClaimBasis
9 services (8 started by default)Counted from the official gitroomhq/postiz-docker-compose file on 2026-11-08: postiz, postiz-postgres, postiz-redis, temporal, temporal-postgresql, temporal-elasticsearch, temporal-admin-tools, temporal-ui, and spotlight (debug profile only).
~2–4 GB+ RAMEstimate for a stack that includes Elasticsearch, two Postgres servers, Temporal and a Node.js app. Not measured.
Startup in tens of secondsEstimate. Not measured.

If you have measured Postiz numbers, please open an issue and we’ll replace these estimates.