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_SECRETencryptscustomFieldscredentials and custom-instance OAuth client data. Provider access tokens in theIntegration.token/refreshTokencolumns 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
| Document | What it covers |
|---|---|
| Quick start | Build, init, connect an agent and post, in five minutes. |
| MCP setup | MCP setup for Claude Desktop, Claude Code, Cursor and remote clients; the 31 tools; the posting flow. |
| CLI | Every CLI command, local vs remote mode, JSON output and exit codes. |
| REST API | /public/v1 endpoints, auth, rate limits and errors. |
| Benchmarks | The measured numbers used on the website, and how to reproduce them. |
| Self-hosting | Install (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 README | Project overview, measured performance, CLI/MCP quick reference, rate limits. |
| Porting status | What 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.
| Mode | Meaning | Examples |
|---|---|---|
oauth | The server returns a login URL; a human authorizes; the platform redirects to FRONTEND_URL/integrations/social/<identifier>. | X, LinkedIn, Reddit, YouTube, Mastodon |
customFields | The 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 |
externalUrl | The 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 |
telegramBot | A human sends /connect <word> to the deployment’s bot, or the agent supplies the numeric chatId. | Telegram |
moltbookClaim | The server registers an agent; a human opens the claim URL; the agent polls. | Moltbook |
signer | The 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).
| Provider | Identifier | What the user supplies |
|---|---|---|
| Bluesky | bluesky | Service URL (default https://bsky.social), handle, app password |
| Dev.to | devto | API key |
| Hashnode | hashnode | API key |
| Lemmy | lemmy | Instance URL, username, password |
| ListMonk | listmonk | URL, username, password |
| M. Instance | mastodon-custom | Instance URL; the server registers an OAuth app on it |
| Medium | medium | API key |
| Moltbook | moltbook | Nothing; the server registers an agent and a human claims it |
| Nostr | nostr | 64-character hex private key (nsec is not accepted) |
| WordPress | wordpress | Site 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.
| Provider | Identifier | What you create |
|---|---|---|
| Discord | discord | Application + bot; client id/secret and bot token |
| Kick | kick | Developer app (OAuth 2.1 + PKCE) |
| Mastodon | mastodon | OAuth app on the instance configured in MASTODON_URL (POST /api/v1/apps); use mastodon-custom for arbitrary instances |
| Slack | slack | Slack app with bot scopes and an icon |
| Telegram | telegram | Bot from BotFather |
| Tumblr | tumblr | OAuth app |
| Twitch | twitch | Developer console app |
| VK | vk | VK ID app |
| Whop | whop | OAuth 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.
| Provider | Identifier | Gate |
|---|---|---|
| Dribbble | dribbble | Application registration/review; scoped tokens |
| Facebook Page | facebook | Meta app in Development mode works for roles/testers; Live mode needs business verification and advanced permissions |
| Instagram (Facebook Business) | instagram | Same Meta app; professional account linked to a Facebook Page; advanced permissions |
| Instagram (Standalone) | instagram-standalone | Separate Instagram app; professional account; testers until review |
linkedin | Products: Share on LinkedIn, Sign In with LinkedIn (OIDC), Advertising API; the org scopes need the Advertising API request | |
| LinkedIn Page | linkedin-page | Same app and scopes; must administer a company page |
pinterest | Trial access works for the app owner; standard access is reviewed | |
reddit | Web app is self-serve, but API access is gated by Reddit’s Responsible Builder review; commercial use is a separate contract | |
| Threads | threads | Meta app + Threads API use case; tester invites until the app is live |
| TikTok | tiktok | Developer app + Content Posting API; unaudited Direct Post is private and limited to a few users/day; audit unlocks privacy levels |
| X | x | OAuth 1.0a app works on the free tier with strict monthly write limits; production volume needs a paid API tier |
| YouTube | youtube | Google 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
| Provider | Identifier | Gate |
|---|---|---|
| Google My Business | gmb | Google My Business API access request (days to a week) and a verified Business Profile |
| MeWe | mewe | MeWe Developer Program is in beta; applications are reviewed and spots are limited |
| TikTok Business | tiktok-business | TikTok Business/Ads developer app and business account; separate credentials and a trailing-slash redirect URI |
| Farcaster | wrapcast | Neynar 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 name | Identifier | Connection | Required server env vars | Redirect URI path |
|---|---|---|---|---|
| Bluesky | bluesky | customFields | — | — (no browser redirect) |
| Dev.to | devto | customFields | — | — |
| Discord | discord | oauth | DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET (set DISCORD_BOT_TOKEN_ID too; posting uses the bot token) | /integrations/social/discord |
| Dribbble | dribbble | oauth | DRIBBBLE_CLIENT_ID, DRIBBBLE_CLIENT_SECRET | /integrations/social/dribbble |
| Facebook Page | facebook | oauth | FACEBOOK_APP_ID, FACEBOOK_APP_SECRET ‡ | /integrations/social/facebook |
| Google My Business | gmb | oauth | GOOGLE_GMB_CLIENT_ID + GOOGLE_GMB_CLIENT_SECRET, or YOUTUBE_CLIENT_ID + YOUTUBE_CLIENT_SECRET ‡ | /integrations/social/gmb |
| Hashnode | hashnode | customFields | — | — |
| Instagram (Facebook Business) | instagram | oauth | FACEBOOK_APP_ID, FACEBOOK_APP_SECRET ‡ | /integrations/social/instagram |
| Instagram (Standalone) | instagram-standalone | oauth | INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET | /integrations/social/instagram-standalone * |
| Kick | kick | oauth | KICK_CLIENT_ID, KICK_SECRET | /integrations/social/kick |
| Lemmy | lemmy | customFields | — | — |
linkedin | oauth | LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET ‡ | /integrations/social/linkedin | |
| LinkedIn Page | linkedin-page | oauth | LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET ‡ | /integrations/social/linkedin-page |
| ListMonk | listmonk | customFields | — | — |
| Mastodon | mastodon | oauth | MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET (optional MASTODON_URL, default https://mastodon.social) | /integrations/social/mastodon |
| M. Instance | mastodon-custom | externalUrl | — | /integrations/social/mastodon (same route) |
| Medium | medium | customFields | — | — |
| MeWe | mewe | oauth | MEWE_APP_ID, MEWE_API_KEY | /integrations/social/mewe |
| Moltbook | moltbook | moltbookClaim | — | — |
| Nostr | nostr | customFields | — | — |
pinterest | oauth | PINTEREST_CLIENT_ID, PINTEREST_CLIENT_SECRET | /integrations/social/pinterest | |
reddit | oauth | REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET | /integrations/social/reddit | |
| Slack | slack | oauth | SLACK_ID, SLACK_SECRET | /integrations/social/slack * |
| Telegram | telegram | telegramBot | TELEGRAM_TOKEN (optional TELEGRAM_BOT_NAME) | — (bot command, no redirect) |
| Threads | threads | oauth | THREADS_APP_ID, THREADS_APP_SECRET | /integrations/social/threads * |
| Tiktok | tiktok | oauth | TIKTOK_CLIENT_ID, TIKTOK_CLIENT_SECRET | /integrations/social/tiktok * |
| TikTok (Business) | tiktok-business | oauth | TIKTOK_BUSINESS_CLIENT_ID, TIKTOK_BUSINESS_CLIENT_SECRET | /integrations/social/tiktok-business/ *† |
| Tumblr | tumblr | oauth | TUMBLR_CLIENT_ID, TUMBLR_CLIENT_SECRET | /integrations/social/tumblr |
| Twitch | twitch | oauth | TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET | /integrations/social/twitch |
| VK | vk | oauth | VK_ID | /integrations/social/vk * |
| Whop | whop | oauth | WHOP_CLIENT_ID | /integrations/social/whop |
| WordPress | wordpress | customFields | — | — |
| Farcaster | wrapcast | signer | NEYNAR_CLIENT_ID, NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC, NEYNAR_SECRET_KEY | — (signer approval, no redirect) |
| X | x | oauth | X_API_KEY, X_API_SECRET | /integrations/social/x (or X_URL base) |
| YouTube | youtube | oauth | YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET | /integrations/social/youtube |
Shared credentials and naming traps
| Trap | Detail |
|---|---|
| Meta app | facebook, 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 app | gmb 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 app | linkedin and linkedin-page share one app and the same scope set; the organization scopes are required for the personal provider too. |
| Dribbble spelling | The 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 secret | The secret variable is KICK_SECRET, not KICK_CLIENT_SECRET. |
| Discord bot | DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET make the provider “configured”, but posting uses DISCORD_BOT_TOKEN_ID; without it channel messages fail. |
| TikTok vs TikTok Business | Never reuse one app’s credentials for the other: tiktok-business is a different API host, scope set and redirect URI. |
| Identifier vs name | wrapcast 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:
| Field | Notes |
|---|---|
provider | Identifier from the table above. Omit when polling connectionId. |
connectionId | Poll a pending OAuth/Telegram/Moltbook/Farcaster connection. |
externalUrl | Self-hosted instance URL; required for mastodon-custom. |
customFields | Credentials for customFields providers. Telegram also accepts {"chatId":"-100..."}; Moltbook {"apiKey":"..."}; Farcaster {"signerUuid":"..."}. |
name | Moltbook agent name (required for a new claim). |
description | Moltbook 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:
- 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). - Server environment variables —
required_env()/required_env_alternatives(), plus optional<PLATFORM>_BASE_URLoverrides. - Developer-app setup — portal URL, products/APIs, exact redirect URI
(including
redirectmeto.comand trailing-slash quirks), exact scopes requested and checked bycheck_scopes(), and sandbox/test-user vs production review. - Connecting a channel — CLI
rustiz channels connect <id>and MCPconnectChannelTool, includingcustomFieldskeys/regexes ortriggerTool/channelSetupToolfollow-ups. - Post settings and media validation —
settings_schema(),validate_settings(),check_validity(), media requirements and plug functions (run_plug/run_post_plug). - 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 cloneURL 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
- MCP setup: Claude, Cursor and remote clients
- CLI and REST API
- Configuration and environment variables
- Self-hosting: systemd, Docker, TLS, backups
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
| Path | TLS | Database / media | Typical use |
|---|---|---|---|
| Local binary (§1.1) | none, plain HTTP on loopback | ~/.rustiz/rustiz.db + ~/.rustiz config | Evaluating, 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 proxy | named volume /data | Reproducible 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/(mode0700on Unix)~/.rustiz/config.env(mode0600) containingJWT_SECRET,DATABASE_URL,FRONTEND_URLandRUSTIZ_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
| Command | HTTP API | MCP | Scheduler | OAuth callback receiver |
|---|---|---|---|---|
rustiz serve | yes | yes (/mcp) | yes (unless --no-worker) | yes |
rustiz serve --no-worker | yes | yes | no | yes |
rustiz worker | no | no | yes | no |
rustiz mcp | no | stdio only | yes | yes, on 127.0.0.1:<FRONTEND_URL port> when the URL is plain HTTP on localhost |
rustiz init | no | no | no | no |
rustiz org ... | no | no | no | no |
rustiz providers | no | no | no | no |
rustiz migrate | no | no | no | no |
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:
--config <path>RUSTIZ_CONFIG=<path>~/.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, optionalexport 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:
customFieldscredential JSON (stored inIntegration.customInstanceDetails)- per-instance OAuth client information (
customInstanceDetails) forexternalUrl/custom-instance providers - the pending
client_infoof anexternalUrlOAuth 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
| Variable | Required | Default | Purpose |
|---|---|---|---|
FRONTEND_URL | yes in production | http://localhost:5000 | Public 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_URL | no | sqlite://rustiz.db | SQLite URL. Docker image sets sqlite:///data/rustiz.db. |
JWT_SECRET | yes for serve/worker/mcp | generated by init | Encryption key for customFields credentials and custom-instance OAuth client data; provider access tokens are not encrypted with it. See §3.2. |
PORT | no | 5000 | HTTP listen port (serve only, via clap/env). |
HOST | no | 0.0.0.0 | HTTP bind address. Use 127.0.0.1 behind a local reverse proxy. |
ALLOW_PRIVATE_NETWORK | no | false | true/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_DIRECTORY | for local storage | unset | Directory for local media, and the root served at /uploads. Without it, local uploads fail and generated JPEGs/thumbnails cannot be stored. |
MAX_UPLOAD_MB | no | 1024 | Maximum request body in MiB for every route (not just multipart). Also cap it in the reverse proxy. |
RUST_LOG | no | info,sqlx=warn,tower_http=info | tracing env filter. Logs go to stderr; stdout is reserved for the MCP stdio protocol. |
4.2 CLI, MCP and config discovery
| Variable | Required | Default | Purpose |
|---|---|---|---|
RUSTIZ_CONFIG | no | ~/.rustiz/config.env | Config file to load before argument parsing. |
RUSTIZ_URL | no | unset | CLI 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_KEY | with RUSTIZ_URL | from config file | Organization API key for the CLI and for rustiz mcp. |
HOME / USERPROFILE | no | OS value | Used 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
STORAGE_PROVIDER | no | local | local, cloudflare or s3. |
Local
| Variable | Required | Purpose |
|---|---|---|
UPLOAD_DIRECTORY | yes | Writes <yyyy>/<mm>/<dd>/<id>.<ext>; served at ${FRONTEND_URL}/uploads/.... Must be on persistent storage. |
Cloudflare R2
| Variable | Required | Default | Purpose |
|---|---|---|---|
CLOUDFLARE_ACCOUNT_ID | yes | — | R2 account id; endpoint is built as https://<account>.r2.cloudflarestorage.com. |
CLOUDFLARE_ACCESS_KEY | yes | — | R2 access key id. |
CLOUDFLARE_SECRET_ACCESS_KEY | yes | — | R2 secret access key. |
CLOUDFLARE_BUCKETNAME | yes | — | Bucket name. |
CLOUDFLARE_REGION | no | auto | R2 region. |
CLOUDFLARE_BUCKET_URL | yes | — | 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
AWS_BUCKETNAME / S3_BUCKETNAME | yes | — | Bucket name. |
AWS_REGION / S3_REGION | no | us-east-1 | Region. |
AWS_ENDPOINT / S3_ENDPOINT | no | AWS default | Custom endpoint (MinIO, Backblaze, etc.). An http:// endpoint enables plain HTTP for the object store. |
AWS_ACCESS_KEY_ID / S3_ACCESS_KEY_ID | no | env/default chain | Access key. |
AWS_SECRET_ACCESS_KEY / S3_SECRET_ACCESS_KEY | no | env/default chain | Secret key. |
AWS_SESSION_TOKEN / S3_SESSION_TOKEN | no | — | Session token for temporary credentials. |
AWS_BUCKET_URL / S3_BUCKET_URL | no | derived | Public 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).
| Variable | Required | Default | Purpose |
|---|---|---|---|
EMAIL_PROVIDER | yes to send | unset = disabled | resend, nodemailer/smtp, or ses. Any other value disables email. |
EMAIL_FROM_ADDRESS | yes | — | From: address. Verify the domain with Resend/SES/SMTP provider. |
EMAIL_FROM_NAME | yes | — | From: display name. |
EMAIL_RATE_INTERVAL_MS | no | priority 1050 ms, bulk 1750 ms | Minimum spacing between queued sends. The default is 3×/5× the 350 ms base interval. |
Resend
| Variable | Required | Default | Purpose |
|---|---|---|---|
RESEND_API_KEY | yes | — | Resend API key; sent as Authorization: Bearer. |
RESEND_API_BASE_URL | no | https://api.resend.com | Base URL override (tests/mocks). The path /emails is appended. |
SMTP / nodemailer
| Variable | Required | Default | Purpose |
|---|---|---|---|
EMAIL_HOST | yes | — | SMTP host. |
EMAIL_PORT | no | 587 | SMTP port. |
EMAIL_SECURE | no | false | true = implicit TLS (SMTPS); false = STARTTLS. |
EMAIL_USER | no | — | SMTP username. Without user+pass the transport is unauthenticated. |
EMAIL_PASS | no | — | 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
SES_REGION | yes unless SES_SMTP_HOST or SES_SMTP_USER is set | us-east-1 | Builds email-smtp.<region>.amazonaws.com. |
SES_SMTP_HOST | no | email-smtp.<region>.amazonaws.com | Override SMTP host. |
SES_SMTP_PORT | no | 465 | SMTP port. |
SES_SMTP_SECURE | no | true | Implicit TLS; false = STARTTLS. |
SES_SMTP_USER / SES_SMTP_USERNAME | conditional | — | SMTP username; falls back to SES_ACCESS_KEY. |
SES_SMTP_PASS / SES_SMTP_PASSWORD | conditional | — | SMTP password; falls back to SES_SECRET_ACCESS_KEY. |
SES_ACCESS_KEY | conditional | — | SES SMTP username alias. |
SES_SECRET_ACCESS_KEY | conditional | — | SES SMTP password alias. |
4.5 Short links
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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DUB_TOKEN | for Dub | — | Dub API token (Authorization: Bearer). Presence selects Dub. |
DUB_API_ENDPOINT | no | https://api.dub.co | Dub API base. |
DUB_API_ENDPOINT_BASE_URL | no | — | Fallback for DUB_API_ENDPOINT (the code appends _BASE_URL). |
DUB_SHORT_LINK_DOMAIN | no | dub.sh | Domain used for links and for “already shortened” detection. |
SHORT_IO_SECRET_KEY | for Short.io | — | Short.io secret key. |
SHORT_IO_API_ENDPOINT | no | https://api.short.io | Short.io API base. |
SHORT_IO_API_ENDPOINT_BASE_URL | no | — | Fallback for SHORT_IO_API_ENDPOINT. |
KUTT_API_KEY | for Kutt | — | Sent as X-API-Key. |
KUTT_API_ENDPOINT | no | https://kutt.it/api/v2 | Kutt API base. |
KUTT_API_ENDPOINT_BASE_URL | no | — | Fallback for KUTT_API_ENDPOINT. |
KUTT_SHORT_LINK_DOMAIN | no | kutt.it | Kutt domain. |
LINK_DRIP_API_KEY | for LinkDrip | — | Sent as Authorization: Bearer. |
LINK_DRIP_API_ENDPOINT | no | https://api.linkdrip.com/v1 | LinkDrip API base. |
LINK_DRIP_API_ENDPOINT_BASE_URL | no | — | Fallback for LINK_DRIP_API_ENDPOINT. |
LINK_DRIP_SHORT_LINK_DOMAIN | no | dripl.ink | LinkDrip 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
| Variable | Required | Default | Purpose |
|---|---|---|---|
API_LIMIT | no | 90 | Per-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_LIMIT | no | 10000 | Per-organization, per-hour limit for /mcp and the other /public/v1 routes. 0 disables; invalid values fall back. |
TRUSTED_PROXY_HOPS | no | 0 | Number 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:
| Endpoint | Limit |
|---|---|
POST /oauth/authorize | 30/hour/address |
POST /oauth/register | 30/hour/address |
POST /oauth/token | 120/hour/address |
4.7 MCP
| Variable | Required | Default | Purpose |
|---|---|---|---|
MCP_ALLOWED_HOSTS | no | FRONTEND_URL host, localhost, 127.0.0.1 | Comma-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_MS | no | 2000 | Poll interval for subscriptions/listen post-state hints. Clamped to 20–60000 ms. |
MCP_SUBSCRIPTIONS_MAX_STREAMS | no | 4 | Concurrent subscriptions/listen streams per organization. Clamped to 1–64. |
MCP_SUBSCRIPTIONS_SEND_TIMEOUT_MS | no | 10000 | How 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
| Variable | Required | Default | Purpose |
|---|---|---|---|
WORKER_CONCURRENCY | no | 32 | Global 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_BINARY | no | ffmpeg | ffmpeg executable used for video thumbnails. It is probed with ffmpeg -version; absence is not an error. |
AUTOPOST_INTERVAL_MS | no | 3600000 | RSS autopost polling interval. |
DIGEST_DELAY_MS | no | 3600000 | Notification digest delay. |
STREAK_REMINDER_MS | no | 79200000 (22 h) | When the streak reminder is queued. |
STREAK_GRACE_MS | no | 7200000 (2 h) | Extra grace before a streak is considered broken. |
4.9 Upload validation and compatibility
| Variable | Required | Default | Purpose |
|---|---|---|---|
RESTRICT_UPLOAD_DOMAINS | no | unset | A 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_PROTECTION | no | false | Bluesky-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_URL | no | unset | Used 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_DIRECTORY | no | unset | Upload 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 toYOUTUBE_CLIENT_ID, and a missing GMB secret toYOUTUBE_CLIENT_SECRET.
MCP setup for agents
Rustiz serves the Model Context Protocol in two ways:
| Transport | Command / URL | Auth | When to use |
|---|---|---|---|
| stdio | rustiz mcp | API key from config.env (or --api-key) | The agent runs on the same machine |
| Streamable HTTP | https://<your-host>/mcp | Authorization: Bearer <api-key>, or OAuth 2.1 + PKCE | Remote 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, orhttps://<host>/mcpwith 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.
| Area | Tools |
|---|---|
| Channels | providersStatusTool, integrationList, integrationSchema, connectChannelTool, triggerTool, channelSetupTool, channelSetTool, groupList, customersTool, mentionsTool |
| Posts | validatePostTool, schedulePostTool, postStatusTool, postsListTool, updatePostTool, deletePostTool, findSlotTool, postSettingsTool |
| Media | uploadFromUrlTool, mediaListTool, mediaDeleteTool |
| Analytics | analyticsTool, postAnalyticsTool, shortLinkStatsTool, notificationsListTool |
| Workspace | tagsTool, 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 opensurlonce, and the agent polls withconnectionId. 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
triggerTooland thenchannelSetupTool.
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 againstDATABASE_URL. No server is needed. Posts are still only published whilerustiz serve,rustiz workerorrustiz mcpis running somewhere against that database. - Remote. With
--url(orRUSTIZ_URL) andRUSTIZ_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 code | Meaning |
|---|---|
| 0 | Success |
| 1 | Server or runtime failure |
| 2 | Bad usage or HTTP 400 |
| 3 | Authentication |
| 4 | Not found |
| 5 | Conflict: the post is being published |
Global options
| Option | Env | Default |
|---|---|---|
--config <path> | RUSTIZ_CONFIG | ~/.rustiz/config.env |
--database-url <url> | DATABASE_URL | sqlite://rustiz.db (init writes the real path) |
--url <url> | RUSTIZ_URL | unset (local mode) |
--api-key <key> | RUSTIZ_API_KEY | from the config file; never printed |
Real environment variables override the config file.
Running
| Command | Purpose |
|---|---|
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 worker | The scheduler only |
rustiz mcp [--api-key <key>] | MCP over stdio, with the scheduler and the local OAuth callback in-process |
rustiz migrate | Apply 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.--openlaunches 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
| Route | Default per hour | Setting |
|---|---|---|
POST /public/v1/posts | 90 per organization | API_LIMIT |
/mcp and every other /public/v1 route | 10,000 per organization | REQUEST_LIMIT |
POST /oauth/authorize, /oauth/register | 30 per client address | fixed |
POST /oauth/token | 120 per client address | fixed |
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
| Method | Path | Notes |
|---|---|---|
GET | /posts?startDate=&endDate=&customer= | ISO 8601 window; returns { posts: [...] } |
POST | /posts | Create 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}/missing | Recent platform posts, for linking a post whose release id is missing |
PUT | /posts/{id}/release-id | Set 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)
| Method | Path | Notes |
|---|---|---|
GET | /integrations | Connected channels |
DELETE | /integrations/{id} | Disconnect |
GET | /is-connected | Checks the API key |
GET | /providers | Every provider with its connection mode |
GET | /providers/status | Readiness: configured, missing env var names |
POST | /integrations/{provider}/connect | Start 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}/time | Replace posting times |
POST | /integrations/{id}/mentions | Look 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
| Method | Path | Notes |
|---|---|---|
POST | /upload | Multipart, field file (png, jpg, gif, webp, mp4, mov, webm, pdf) |
POST | /upload-from-url | { "url": "https://..." } |
GET | /media?page= | Media library |
GET | /media/{id}/status | Processing 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
| Method | Path | Notes |
|---|---|---|
GET | /analytics/{channelId}?date=<days> | Channel analytics |
GET | /analytics/post/{postId}?date=<days> | Post analytics |
GET | /notifications | Recent notifications |
Workspace
| Method | Path |
|---|---|
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
| Method | Path |
|---|---|
GET | /debug/posts/{id} (timeline of one post) |
GET | /debug/account |
GET | /debug/channels |
GET | /debug/activity |
Outside /public/v1
| Path | Purpose |
|---|---|
GET /health | Returns ok |
/mcp | MCP 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:
| Endpoint | Purpose |
|---|---|
GET /.well-known/oauth-protected-resource and .../mcp | RFC 9728 resource metadata (resource, authorization_servers, bearer_methods_supported, scopes_supported: ["rustiz"]). |
GET /.well-known/oauth-authorization-server | RFC 8414 authorization-server metadata. |
GET/POST /oauth/authorize | Consent page. It asks for the organization API key; there is no user login. |
POST /oauth/token | Authorization-code and refresh-token grants, token_endpoint_auth_method: none. |
POST /oauth/register | RFC 7591 dynamic client registration (for older clients). |
Details an operator may need:
- Only
response_type=code,S256PKCE and a code challenge of at least 43 characters are accepted.resourcemust be${FRONTEND_URL}/mcpor${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 matchingclient_idandredirect_uris. - Registration accepts
httpsURIs andhttponly for loopback hosts (localhost,127.0.0.1,[::1]); at authorization the requestedredirect_urimust 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-referrerandCache-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:
| Argument | Use |
|---|---|
provider | Provider identifier. Not needed when polling a connectionId. |
connectionId | Poll a Telegram/Moltbook/Farcaster/OAuth handshake started earlier. Safe to repeat. |
externalUrl | Self-hosted instance URL (required for mastodon-custom). |
customFields | Credential object for customFields providers. Telegram also accepts {"chatId":"-100..."}, Moltbook {"apiKey":"..."}, Farcaster {"signerUuid":"..."}. |
name | Moltbook agent name to register. |
description | Moltbook 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 nocustomFields, the server returnsconnectionId, awordand 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 sameconnectionId. WithcustomFields.chatIdit connects immediately. - Moltbook (
moltbookClaim): the server registers an agent and returnsclaimUrl(and possibly a verification code). The human claims it, then the agent polls. - Farcaster (
signer): the server creates a Neynar signer and returnsapprovalUrl/qrCode; the human approves in the Farcaster app, then the agent polls. WithcustomFields.signerUuidof 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 withinBetweenSteps=true. List the choices withtriggerTooland finish withchannelSetupTool:
{
"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
validatePostToolchecks channel state (disabled, refresh needed, between steps), providervalidate_settings, character count (HTML stripped, counted in characters), thread support andcheck_validitymedia rules. It does not create anything.schedulePostToolacceptssocialPost[]withintegrationId,date(UTC),type(draft/schedule/now), optionalshortLink,settingsas[{key,value}]andpostsAndComments([0]is the post, the rest are replies).attachmentsmay be a URL string or an object withpath,alt,thumbnail,thumbnailTimestamp.- Resends are safe: an identical call within 10 minutes, or the same
idempotencyKeywithin 24 hours, returns the first result. Reusing a key with different arguments is rejected. An in-flight or abandoned claim tells you to checkpostsListToolbefore retrying with a new key. - With the MCP Tasks extension,
type:"now"returns a durable task handle; otherwise pollpostStatusTool. Task state is derived from posts and survives restarts. subscriptions/listenonrustiz://posts/<postId>orrustiz://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 withtower-http’sServeDir. - 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.
5.3 Recommended R2 lifecycle rules
R2 lifecycle rules are bucket-wide (optionally prefix-filtered) and cannot ask SQLite which objects a post still references. Therefore:
- 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. - 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.
- A safe pattern is: run a reconciliation job that copies objects no longer
referenced by
Media/Postrows intotrash/<yyyy>/<mm>/, then apply a 30-day expiration totrash/and let R2 delete it. Keep live media in the normal prefix with no expiry. - 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/httpsis allowed. The guard blocks loopback, private, link-local and metadata addresses unlessALLOW_PRIVATE_NETWORK=true. - Local media reads are confined to
UPLOAD_DIRECTORYwith lexical path normalisation;../and sibling prefixes such asuploads-evilare 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/listenstream 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_MBand 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=2and firewall the origin so it is reachable only from Cloudflare. With Cloudflare connecting directly to Rustiz,1is 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-businessends with/(.../tiktok-business/); the platform requires it and the token exchange sends the same value.mastodon-customreuses/integrations/social/mastodon.xuses/integrations/social/x, unlessX_URLoverrides the base.threads,slack,vk,tiktok,tiktok-businessandinstagram-standalonewrap the URI ashttps://redirectmeto.com/<FRONTEND_URL>/...whenFRONTEND_URLdoes not contain the substringhttps. In production this should never happen; use HTTPS.facebook,instagram,pinterest,reddit,linkedin,youtube,gmb,tumblr,twitch,discord,dribbble,whopandmeweare registered directly; several of them refuse plainhttp://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
- Back up.
- Build/copy the new binary.
rustiz migrate(or just start; migrations run automatically).- Restart
serveandworker; graceful shutdown closes MCP subscription streams. - Check
/healthandproviders --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
servereplicas 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
workerunless 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_URLis HTTPS and matches the registered redirect URIs. -
JWT_SECRETis random and stable,config.envis0600, 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_NETWORKis 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_HOPSmatches the real proxy chain and the origin is firewalled to those proxies. - The
/datavolume andUPLOAD_DIRECTORYare 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_LOGdoes not log request bodies containing credentials (the port redacts bodies stored inErrors, but proxy logs are your responsibility).
Troubleshooting
9. Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
JWT_SECRET must be set | serve/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 again | OAuth state older than 1 hour or already consumed. Restart the connect flow. |
timed out after 300s waiting for the login to finish | Human 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_mismatch | Compare 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_id | Provider credentials missing or wrong names (e.g. DRIBBBLE_* not DRIBBLE_*, KICK_SECRET not KICK_CLIENT_SECRET). rustiz providers --status shows names. |
429 / ThrottlerException | API_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 Large | MAX_UPLOAD_MB or the proxy body limit. Raise both, and check Cloudflare’s plan limit. |
/uploads/... 404 | UPLOAD_DIRECTORY unset or not the directory containing the file; proxy strips /uploads; wrong FRONTEND_URL. |
| Video has no thumbnail, but post succeeds | ffmpeg 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 key | Wrong header; use Authorization: Bearer <key>. OAuth clients must complete discovery/consent; check WWW-Authenticate. |
| MCP 400/403 about Host | Add the exact host:port to MCP_ALLOWED_HOSTS; ensure the proxy preserves Host. |
subscriptions/listen disconnects | Proxy 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 getUpdates | Two processes use the same bot token. Use one token per deployment; stop the duplicate. |
database is locked | Another 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 used | A required storage/email value is absent. The message names the variable; check §4. |
| R2 objects 403 / broken images | CLOUDFLARE_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 missing | Callback 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 finished | is_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
| Property | Value |
|---|---|
| Identifier | bluesky |
| Display name | Bluesky |
| Connection mode | customFields (service URL, identifier, app password) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; replies use app.bsky.feed.post with a reply ref |
| Pending / async publishing | Yes. supports_pending = true; video is started in post_pending() and polled before createRecord |
| Analytics | No |
| Post analytics | No |
| Mentions | Yes. supports_mentions = true; app.bsky.actor.searchActors |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 6 |
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 scopes | None. 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
BLUESKY_VIDEO_BASE_URL | No | https://video.bsky.app | Overrides the video service origin. Trailing slash trimmed. Read through Config::base_url("BLUESKY_VIDEO", …). |
DISABLE_SSRF_PROTECTION | No | unset | When 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_SECRET | Yes in practice | empty | Encrypts 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
- Sign in at https://bsky.app.
- Open Settings → Privacy and security → App Passwords (the direct URL is https://bsky.app/settings/app-passwords).
- Choose Add App Password, name it
Rustiz, and copy the generated password. It looks likexxxx-xxxx-xxxx-xxxx. - 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 type | service value |
|---|---|
| Normal Bluesky-hosted account | https://bsky.social |
| Custom-domain handle on bsky.social | https://bsky.social (the handle is the identifier) |
| Self-hosted PDS | The PDS origin, e.g. https://pds.example.com |
| Local PDS for testing | http://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
createSessionanswersAuthFactorTokenRequired, the Rust port has noauthFactorTokenhandling 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
| Key | Type | Default | Validation regex (exact string from custom_fields()) | Notes |
|---|---|---|---|---|
service | text | https://bsky.social | See the exact regex below | PDS 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. |
identifier | text | — | /^.+$/ | Handle, DID or email. A single leading @ is stripped by login_identifier(). |
password | password | — | /^.{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
.mp4may be the only media in a post. - An
.mp4plus 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.MP4bypasses the rule, whilehas_extension()later treats it as a video. This is a port bug: a.MP4plus 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
| Media | Bluesky limit | Rustiz behavior |
|---|---|---|
| Images | Up 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. |
| Video | One 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 text | Per image | Preserved in app.bsky.embed.images[].alt; optional. |
| Mixed | Not allowed | check_validity() rejects an .mp4 with any other media (except the case-sensitivity hole above). |
| GIF | Displayed as a static image | Rust 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|\()@handlewhere the handle is a valid domain; resolved withcom.atproto.identity.resolveHandleand stored as aapp.bsky.richtext.facet#mentionwith adid. - Links:
http(s)://…or a bare domain with a valid TLD; trailing punctuation and unmatched)are trimmed; stored asapp.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 asapp.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):
| Method | Fields | Behavior |
|---|---|---|
autoRepostPost | likesAmount | Reads the post thread, compares likeCount >= likesAmount, waits 2 s, then creates an app.bsky.feed.repost record. |
autoPlugPost | likesAmount, post | Same 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 iscustomFields; without it the agent gets a bogus URL (see above). - Login on every publish. The stored
accessJwtis not reused for publishing:get_agent()callscom.atproto.server.createSessionwith the stored handle/app password for every post, comment, mention search and plug. This is simple but burns thecreateSessionrate 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 andexpires_in: 0. The storedrefreshJwtis never used. A 4xx login (other than 429) is classified asrefresh_token, so the channel is marked for reconnect. There is nocom.atproto.server.refreshSessionimplementation. - Two-phase duplicate protection. Bluesky uses a prepare/commit handshake
in
pending_data:post_pending()starts the video job (if any) and stores{jobId, message, media}.check_post_status()polls the video job. When ready,finalize_post()is called.- The first
finalize_post()only setsattempting=true, confirmed=falseand returnsPending. - The next
check_post_status()seesattempting=trueand setsconfirmed=true; it returnsReady. - The second
finalize_post()performs the upload/createRecordand returnsCompleted. If a create attempt is uncertain, the nextcheck_post_status()seesattempting && confirmedand refuses with “Bluesky may have already published this post…”. This is deliberate:createRecordis the one irreversible step and is called withexecute(no retry, no 401 reclassification).
- Preparation failures are disarmed. If login or blob upload fails before
createRecord,prep_failure()clearsattempting/confirmedand incrementsprepFailures; 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()callscom.atproto.server.getServiceAuthwithaud=did:web:<PDS host>,lxm=com.atproto.repo.uploadBlob,exp=now+30m, then streams the MP4 toapp.bsky.video.uploadVideo?did=…&name=….getJobStatusis public (no auth). The job must reachblob != null;JOB_STATE_FAILEDis a hard error. The blockingupload_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_memorydecodes the first frame; the re-encode is a still image. Bluesky displays GIFs as static images anyway, but do not expect animation. - Only
.mp4is recognized as video.has_extension(path, "mp4")is the only video test. A.mov,.webmor.m4vis treated as an image: it is decoded with theimagecrate 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_MBand the reverse proxy limit are the practical caps. - Facet resolution can fail softly. If
resolveHandlefails, the mention facet is still emitted with an emptydid; 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 severalchars) 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;
createSession30 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 exhaustcreateSessionbefore it exhausts record writes. max_concurrent_job = 6is 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 theat://URI returned bycreateRecord. The storedpost_idis the full AT URI, not just the rkey. - Threads.
comment()callsapp.bsky.feed.getPostThread?uri=…&depth=0to get the parentcidand the root ref. For a top-level parent, it uses the parent as both root and parent. For a reply, it readsrecord.reply.root. The reply’s text and media go through the same facet and embed logic. - Mentions.
mention()callsapp.bsky.actor.searchActors?q=…and mapshandletoid,displayNametolabel,avatartoimage. The engine’smention_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()requireshttps, rejectslocalhost, resolves the host and rejects private/blocked IPs. It runs only duringauthenticate(), 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 asdid:web:<host>. A self-hosted PDS that is not addressed by adid:webmatching its hostname may fail video service auth; text and image posts still work.
References
- AT Protocol / Bluesky API overview: https://docs.bsky.app/docs/get-started
- App passwords: https://bsky.app/settings/app-passwords
com.atproto.server.createSession: https://docs.bsky.app/docs/api/com-atproto-server-create-sessioncom.atproto.repo.createRecord: https://docs.bsky.app/docs/api/com-atproto-repo-create-recordcom.atproto.repo.uploadBlob: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob- Video service (
app.bsky.video.*): https://docs.bsky.app/docs/api/app-bsky-video-upload-video - Rich text facets: https://github.com/bluesky-social/atproto/tree/main/packages/api/src/rich-text
- Bluesky rate limits: https://docs.bsky.app/docs/advanced-guides/rate-limits
- Postiz Bluesky setup: https://docs.postiz.com/self-host/providers/bluesky
- Postiz Bluesky settings: https://docs.postiz.com/public-api/providers/bluesky
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
| Property | Value |
|---|---|
| Identifier | devto |
| Display name | Dev.to |
| Connection mode | customFields (API key collected by the connect form / MCP) |
| Editor | markdown |
| Channel kind | social (article content) |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published |
| Pending / async publishing | No. supports_pending = false; post() returns PostStatus::Completed |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 100000 characters for the article body |
| API version | Forem 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DEVTO_BASE_URL | No | https://dev.to | Overrides 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_DOMAINS | No | unset | If 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.
-
Sign in to the Dev.to account that should publish the articles.
-
Open Settings → Extensions: https://dev.to/settings/extensions.
-
Click Generate API Key (the UI may call it Generate New Key).
-
Copy the key. Treat it as a password: it can create, update and unpublish articles on that account. There are no scopes to select.
-
Optionally verify it before connecting:
curl -s https://dev.to/api/users/me \ -H "api-key: $DEVTO_API_KEY"The response contains
id,name,usernameandprofile_image, which are exactly the fields Rustiz stores duringauthenticate.
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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
apiKey | password | /^.{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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
tags | none | [{"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. |
organizations | none | [{"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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | Yes | minLength: 2. Also checked by validate_settings. |
main_image | object | No | Cover image. Properties: id (string, required by the schema), path (string, required), alt (string), thumbnail (string URL). additionalProperties: true. |
canonical | string | No | Canonical URL sent as canonical_url. If it contains (post: it is skipped by URL validation. |
organization | string | No | Dev.to organization id sent as organization_id. Leave empty to publish as the user. |
tags | array | Yes (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
titlemust exist and be a string; at least 2 Unicode scalar values.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, anhttp(s)://URL, or awww.host with a dot and a non-space tail.organization, if present and notnull, must be a string.tags, if present and notnull, must be an array with at most 4 items. Each item must be an object with a numericvalueand a stringlabel.main_image, if present and notnull:- must be an object;
idmust be a string;pathmust be a string;- if
RESTRICT_UPLOAD_DOMAINSis set,pathmust contain that value; - the path (query string stripped) must end in
.png,.jpg,.jpeg,.gif,.webpor.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; themedia/attachmentsarray 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’smain_imageis 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-codespublished: 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
Acceptheader. Forem’s v1 documentation says v1 requests should sendAccept: application/vnd.forem.api-v1+json. Rustiz sends onlyapi-key. The tests and ordinaryapi-keyendpoints 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
organizationto be a string, andpost()sends"organization_id": "<string>". Dev.to’s article schema documentsorganization_idas an integer. If Dev.to rejects the organization, this type mismatch is the cause. Omittingorganizationavoids it. (post:canonical placeholder is only skipped in validation. If a caller passes a string containing(post:,validate_settingsaccepts it, butpost()still sends the non-empty value ascanonical_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 onlylabel. Renaming a tag in the editor changes what is sent; the numeric id is not used by Dev.to on create. tags[].valuemust be a number in Rustiz.docs.postiz.comshows string values in some examples. The Rust validator explicitly requiresValue::is_number; string tag values fail witheach tag needs a numeric \value` (tag id)`.organizationsis 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.
authenticatestores the key with a 100-yearexpires_inandrefresh_token: Some("").refresh_token()returns an empty access token withexpires_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(testunauthorized_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 takenis converted to the actionable messageCanonical 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
- Forem API overview: https://developers.forem.com/api
- Forem API v1 reference: https://developers.forem.com/api/v1
- Dev.to API key page: https://dev.to/settings/extensions
- Postiz provider settings: https://docs.postiz.com/public-api/providers/devto
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | discord |
| Display name | Discord |
| Connection mode | oauth (a human opens Discord’s consent/install page) |
| Editor | markdown |
| Channel kind | social |
| Comments / threads | Yes. 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 publishing | No. supports_pending = false; posting is a single multipart request |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | Yes. supports_mentions = true; roles, members, @here and @everyone |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 5 — generous for webhook-style message posting |
Character limit (max_length) | 1980 characters (Discord’s hard limit is 2000) |
| OAuth scopes | identify, guilds checked; the authorize URL also requests bot |
| Redirect URI | {FRONTEND_URL}/integrations/social/discord |
| API host | https://discord.com (override DISCORD_BASE_URL) |
| CDN host | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DISCORD_CLIENT_ID | Yes | — | Application id from the Developer Portal |
DISCORD_CLIENT_SECRET | Yes | — | OAuth2 client secret |
DISCORD_BOT_TOKEN_ID | Yes 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_URL | No | https://discord.com | Origin for OAuth and /api/... calls, and for release URLs. Trailing slash stripped. |
DISCORD_CDN_BASE_URL | No | https://cdn.discordapp.com | Origin for avatar URLs (/avatars/{user}/{hash}.png). Trailing slash stripped. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the OAuth redirect URI |
JWT_SECRET | Yes in practice | empty | Encrypts 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
- Sign in to Discord and open the Developer Portal.
- Click New Application, name it (e.g.
Rustiz), accept the terms. - 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.
- 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
- Open Bot in the Developer Portal.
- Set the bot’s username/avatar as desired.
- Click Reset Token and copy the token. This is the value for
DISCORD_BOT_TOKEN_ID— it is a token, not an application id. - 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:
| Permission | Bit | Included |
|---|---|---|
SEND_MESSAGES | 1 << 11 | yes |
CREATE_PUBLIC_THREADS | 1 << 35 | yes |
CREATE_PRIVATE_THREADS | 1 << 36 | yes |
SEND_MESSAGES_IN_THREADS | 1 << 38 | yes |
VIEW_CHANNEL | 1 << 10 | no (normally granted by @everyone) |
ATTACH_FILES | 1 << 15 | no (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_IDis 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;
--openlaunches the browser. - With a local
FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to--timeoutseconds (default300) for the new channel. - With a public HTTPS
FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll withrustiz channels connect-status <connectionId>orrustiz 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’sexternal_url(), which returnsUnsupported("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
channelmust 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.
alttext is not sent; the attachment description is hard-coded asPicture <index>.
Threads and comments
supports_comments = true.
- The first comment calls
POST /api/channels/{channel}/messages/{post_id}/threadswith{"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()receiveslast_comment_idas the previous comment’s message id, and then uses it as a channel id for the nextPOST /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.
releaseURLfor 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()andcomment()ignore the OAuthaccess_tokenentirely and sendAuthorization: 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
botscope in the authorize URL adds it during install. If someone removes the bot from the server,channelsreturns50001and 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 includeguild.id(for example the bot was already installed and Discord omitted theguildobject),internal_idis 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.avataris 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,pictureandusernameare empty. The engine only writes the token columns, so this is normally harmless. Do not callrefresh_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) passeslast_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 storedthread_idinpending_data/PostResponse, so the provider cannot recover the thread channel id later. - Forum channels are listed but not really supported.
channels()includes type15(forum), but posting to a forum channel requires creating a thread with athread_name/forum payload, not a plain/channels/{id}/messagescall. 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 withid = "&<role id>"andlabel = name.split('@').nth(1). A normal role name likeModeratorhas no@, so the label is an empty string. The id is still correct andmention_format()converts[[[@&123]]]to<@&123>, but a UI that displayslabelwill show blank role options. - Mentions: member search needs the bot in the guild.
members/searchis 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).@hereand@everyoneare special-cased and not cached. - Mentions:
@here/@everyoneare 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 hasMENTION_EVERYONE; the fixed permission integer377957124096does 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 channel50013→Bot lacks permission to send messages in this channel10003→Channel no longer exists40005→Attachment exceeds Discord's size limit20028→Rate limited by Discord(retry)401: Unauthorized→Discord rejected the Rustiz bot token, reconnecting will not helpA401with 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_mediabypasses this and accepts200..=202.channels,create_threadandchange_nicknameusesend_json, so a204would 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.429responses are retried by the shared HTTP layer. - Invalid-request Cloudflare ban. Discord temporarily blocks IPs that make
too many
401/403/429requests (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_nicknameneeds the guild id and permission. It callsPATCH /api/guilds/{id}/members/@mewith{"nick": name}. The requested permission bit set does not includeCHANGE_NICKNAME; a bot can normally change its own nickname, but a restrictive role setup may return403.max_lengthis 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 normaldiscord.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
- Discord Developer Portal: https://discord.com/developers/applications
- OAuth2: https://docs.discord.com/developers/topics/oauth2
- Permissions and bit fields: https://docs.discord.com/developers/topics/permissions
- Rate limits: https://docs.discord.com/developers/topics/rate-limits
- App verification (100+ guilds): https://support-dev.discord.com/hc/en-us/articles/23926564536471
- Privileged intents (10,000-user threshold): https://support-dev.discord.com/hc/en-us/articles/6205754771351
- Search Guild Members: https://docs.discord.com/developers/resources/guild#search-guild-members
- Postiz Discord setup: https://docs.postiz.com/providers/discord
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
teamsetting is validated but never sent. See Implementation Notes & Gotchas.
Overview
| Property | Value |
|---|---|
| Identifier | dribbble |
| Display name | Dribbble |
| Connection mode | oauth (authorization code; no refresh token in practice) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published |
| Pending / async publishing | No. supports_pending = false; Dribbble’s 202 + Location is treated as completed immediately |
| Analytics | supports_analytics = true, but analytics() returns an empty array. The flag advertises the capability; there is no actual data. |
| Post analytics | supports_post_analytics = true, but post_analytics() returns an empty array. |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 40000 characters for the shot description/body |
| API | Dribbble 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DRIBBBLE_CLIENT_ID | Yes | none | OAuth application client id. |
DRIBBBLE_CLIENT_SECRET | Yes | none | OAuth application client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the OAuth redirect URI. Must be the public origin Dribbble can reach; a trailing slash is stripped. |
DRIBBBLE_OAUTH_BASE_URL | No | https://dribbble.com | Overrides the authorize/token origin. The paths appended are /oauth/authorize and /oauth/token. |
DRIBBBLE_BASE_URL | No | https://api.dribbble.com | Overrides the API origin. Paths appended are /v2/user and /v2/shots. |
PINTEREST_BASE_URL | No | https://api-sandbox.pinterest.com | Only used by the broken refresh path. It does not affect normal publishing. |
PINTEREST_CLIENT_ID | No | none | Read only by the broken refresh path; not declared in required_env(). |
PINTEREST_CLIENT_SECRET | No | none | Read 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
-
Sign in to Dribbble and open https://dribbble.com/account/applications/new.
-
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/dribbbleFor example
https://social.example.com/integrations/social/dribbble. Do not add a trailing slash.
-
-
Save the application and copy the Client ID and Client Secret. The Postiz Dribbble guide says both are 64 characters long.
-
Set them on the server:
DRIBBBLE_CLIENT_ID="your-client-id" DRIBBBLE_CLIENT_SECRET="your-client-secret" FRONTEND_URL="https://social.example.com" -
Restart Rustiz and check:
rustiz providers --status | jq '.[] | select(.identifier=="dribbble")'configuredshould be true with no missing variables.
Spelling warning. The Postiz documentation page for Dribbble currently shows
DRIBBLE_CLIENT_ID/DRIBBLE_CLIENT_SECRET(twobs, not three) and a redirect path/integrations/social/dribble(one fewerb). Those are typos. This Rust port usesDRIBBBLE_CLIENT_ID/DRIBBBLE_CLIENT_SECRETand the correct/integrations/social/dribbblepath. 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:
| Deployment | Redirect URI to register |
|---|---|
Production https://social.example.com | https://social.example.com/integrations/social/dribbble |
Local default FRONTEND_URL | http://localhost:5000/integrations/social/dribbble |
| A local frontend on port 4200 | http://localhost:4200/integrations/social/dribbble |
| A Docker frontend on port 5000 | http://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_forexists 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-RemainingandX-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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
teams | none | [{"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
teamsresult cannot currently be used for posting. Theteamsetting is ignored bypost(), and Dribbble requires a numericteam_idfield in the shot upload, which the provider never sends. Treatteamsas informational only.
Post Settings & Media Validation
settings_schema()
CLI note: the simple
rustiz posts create --channel <id> --content "..."form cannot pass provider settings. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | Yes | minLength: 1; must be a non-empty string. No maximum is enforced by Rustiz. This is Dribbble’s required shot title. |
team | string | No | Described 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
titlemust exist and be a string; it must not be empty.team, if present and notnull, 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 becausedocs.postiz.comexamples use"team": "". - Use
nullor omitteamfor a personal shot.
- An empty string is not a URL and fails with
- 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:
- If any item’s path contains
"mp4"(case-sensitive), returnDoes not support mp4 files. - Otherwise, the part must have exactly one media item. Zero or two or
more returns
Requires one item. - 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:
titlefromsettings.title;descriptionfromfirst.message.
- It does not send
team_id,tags,low_profile,rebound_source_idorscheduled_for. - Dribbble’s API responds 202 Accepted with a
Locationheader such ashttps://api.dribbble.com/v2/shots/471756. Rustiz treats any 2xx as success, takes the last path segment as the post id, and returnshttps://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 becomeshttps://dribbble.com/shots/— a silent bad success. - A 4xx other than 429 is converted to a
bad_bodyerror using the platformmessagefield, orDribbble 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
refreshTokencalls the Pinterest sandbox endpoints withPINTEREST_CLIENT_ID/PINTEREST_CLIENT_SECRET. That copy paste bug is kept here.
In refresh_token() (around lines 258–318):
-
It builds Basic auth from
PINTEREST_CLIENT_IDandPINTEREST_CLIENT_SECRET. -
It posts to
{PINTEREST_BASE_URL}/v5/oauth/token, defaulthttps://api-sandbox.pinterest.com/v5/oauth/token. -
The form contains
grant_type=refresh_token, the Dribbble refresh token,scope=public,upload, and aredirect_uriof:{FRONTEND_URL}/integrations/social/pinterestThat is
pinterest, notdribbble— line 283 ofdribbble.rs. -
It then calls
{PINTEREST_BASE_URL}/v5/user_accountwith the returned token and returns aAuthTokenDetailswhoseid,name,usernameandpictureare 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_SECRETare not declared inrequired_env(), so the deployment’s provider status will not warn about them.- Normal Dribbble operation tries to avoid refresh:
authenticate()setsexpires_in: Some(999999999)(about 31.7 years) andrefresh_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 dribbblerather than trying to repair the token.
Other gotchas
-
The
teamsetting is dead code. It is in the schema and validated, butpost()never reads it. Dribbble’s create-shot API wants a numericteam_id; theteamstool 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, butis_url("")is false, so the call fails withteam must be a URL. Omit the key or sendnull. -
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_inis synthetic. Dribbble’s OAuth token response containsaccess_token,token_typeandscope, but noexpires_in. Rustiz stores999999999seconds so the engine will not try to refresh. This is a deliberate workaround, not data from Dribbble. -
No refresh token is stored.
authenticate()setsrefresh_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. -
analyticsflags are misleading.info.supports_analyticsandinfo.supports_post_analyticsaretrue, so generic callers may ask for analytics, but both methods returnOk(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 postCompletedimmediately. A later analytics/post lookup at the returnedhttps://dribbble.com/shots/{id}may briefly fail. -
Missing
Locationis a silent empty success. The code does not treat a 2xx withoutLocationas an error; it returns an empty post id andhttps://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 = 3keeps 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-specifichandle_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 beforemessagereaches 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’shtml_urlfield is not used.
References
- Dribbble OAuth documentation (authorize/token, redirect rules, scopes, errors): https://developer.dribbble.com/v2/oauth/
- Dribbble create-shot documentation (400×300 or 800×600, 8 MB, GIF/JPG/PNG, video unsupported, 202 + Location, team_id, rate limits): https://developer.dribbble.com/v2/shots/
- Dribbble API overview and rate limits: https://developer.dribbble.com/v2/
- Dribbble application registration: https://dribbble.com/account/applications/new
- Postiz Dribbble self-host guide (contains the
DRIBBLE_*/dribbletypos — use the Rust names instead): https://docs.postiz.com/self-host/providers/dribbble - Postiz provider settings (note the
"team": ""example, which Rustiz rejects): https://docs.postiz.com/public-api/providers/dribbble - Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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:
instagramandinstagram-standaloneshare the same Meta app family, but onlyinstagramreusesFACEBOOK_APP_ID/FACEBOOK_APP_SECRET
Overview
| Property | Value |
|---|---|
| Identifier | facebook |
| Display name | Facebook Page |
| Connection mode | oauth (Facebook Login for Business) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries are posted as POST /{post-id}/comments |
| Pending / async publishing | Yes for stories (supports_pending = true); ordinary feed posts are published synchronously inside post_pending() and return PostStatus::Success |
| Analytics | Yes. supports_analytics = true; Page Insights |
| Post analytics | Yes. supports_post_analytics = true; feed post insights or video insights |
| Mentions | No. supports_mentions = false |
is_between_steps | true — the OAuth callback creates an unfinished channel; a Page must be selected with channelSetupTool |
convert_to_jpeg | false |
max_concurrent_job | 500 (the code comment: “Facebook has reasonable rate limits”) |
Character limit (max_length) | 63,206 characters |
| Graph API version | v25.0 (META_GRAPH_API_VERSION) |
| API base | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
FACEBOOK_APP_ID | Yes | — | Meta app ID shown at the top of the App Dashboard. Used as client_id in the login dialog and token exchange. |
FACEBOOK_APP_SECRET | Yes | — | Meta app secret. Used server-side for code → token and short-lived → long-lived token exchange. |
FACEBOOK_BASE_URL | No | https://graph.facebook.com | Graph API origin. Config::base_url("FACEBOOK", …). |
FACEBOOK_WEB_BASE_URL | No | https://www.facebook.com | Origin used for the /v25.0/dialog/oauth login URL. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the OAuth redirect URI. Trailing slash stripped. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state/client information. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global upload guard; not read directly by facebook.rs, but media URLs must be reachable by Meta. |
META_GRAPH_API_VERSION | No | — | 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
- Sign in at https://developers.facebook.com/apps/creation/.
- Choose Other → Business as the app type. A Business portfolio is strongly recommended; public apps need Business Verification.
- Enter the app name, contact email and (optionally) a Business portfolio.
- In the App Dashboard, add the product Facebook Login for Business.
- 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 theredirect_uriin 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.
| Scope | Why Rustiz needs it |
|---|---|
pages_show_list | List Pages in /me/accounts and Business Manager edges |
business_management | Read /me/businesses, owned_pages, client_pages |
pages_manage_posts | Create feed posts, photos, videos, stories |
pages_manage_engagement | Create comments/replies as the Page |
pages_read_engagement | Read Page content and basic metadata |
read_insights | Page Insights and post Insights |
Missing
publish_video: Meta’s Pages API documentation listspublish_videoas 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, whichhandle_errorsalready maps to a friendly message. Addpublish_videoto 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:
GET /v25.0/me/accounts?fields=id,username,name,access_token,picture.type(large)&limit=100GET /v25.0/me/businesses, thenowned_pagesandclient_pagesfor each business, followingpaging.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_managementandread_insightsrequires 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
GET /v25.0/me/accountsreturns the Page with a non-emptyaccess_token.GET /v25.0/{page-id}?fields=name,fan_countworks with that token.- A text-only
POST /{page-id}/feedsucceeds. - A photo post succeeds (JPEG/PNG < 4 MB).
- A video post succeeds only if
publish_video(or the equivalent Page task) is granted. - 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
-
List the Pages the token can publish to:
{ "integrationId": "<unfinished channel id>", "methodName": "pages", "dataSchema": [] }facebookexposes 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}}}, …]. -
Choose a Page and call:
{ "integrationId": "<unfinished channel id>", "data": { "page": "<page id>" } }channelSetupToolcallsFacebook::fetch_page_information(token, {"page": "<id>"}), which re-reads/me/accountsand Business Manager, thenfinish_integration_setupreplaces the channel’sinternalId,name,picture,profileandtokenwith the Page values and clearsinBetweenSteps. -
Verify with
integrationList: the channel name is now the Page name andinBetweenStepsisfalse.
There is exactly one data key for Facebook: page. Do not send
{"id": …} (that is the Instagram shape) or {"pageId": …}.
What not to send
customFieldsroutes toconnect_custom()and treats the object as an OAuth authorization code. Never send it tofacebook.externalUrlis not used by Facebook; it is ignored byconnect.rs(onlymastodon-customrequires 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
}
| Field | Type | Behaviour |
|---|---|---|
url | string | Sent 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_id | string | Sent only for text-only posts of at most 130 UTF-16 code units. If Facebook rejects the preset, the provider retries once without it. |
title | string | Sent 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()
settingsmust be an object.post_type, when non-null, must be exactlypostorstory.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 fullhttp(s)URL or a scheme-less host containing a dot (the code retries withhttps://prefixed).example.com/xis accepted;not a urlis 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
| Media | How Rustiz sends it | Platform 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 attachment | First media only, sent as attachment_url | A link attachment, not an uploaded photo |
| Link preview | link field on /feed | Must 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, thenfinalize_post()publishes exactly one item per call. A two-photo story produces two separate stories. - Video detection is
.mp4only.has_extension(video_path, "mp4")looks for the substring.mp4anywhere in the path (case-insensitive). A.movfile 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 thepermalink_urlthat 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.altis never sent to Facebook. - A feed post with both
linkand media may be ignored or rejected. The code sendslinkeven whenattached_mediais present. The schema saysurlis for text posts without media; treat that as the supported path.
Threads
supports_comments = true. A multi-entry post works as follows:
- The first entry is published as a feed post, photo post, video post or story.
- Each later entry is published by
comment()against the previous post id (or the root post id for the first comment). comment()sendsmessageplus, if the comment has media, the first media item asattachment_url.- 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. OnlychannelSetupToolswaps 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_inis hard-coded to59 * 24 * 3600.refresh_token()is a no-op (returns an empty access token withexpires_in: 0), andrefresh_cronisfalse. There is no proactive refresh. When the token expires, publishing gets a190/Error validating access token, the engine marks the channelrefreshNeeded, 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-authenticatedAuthTokenDetails.idto equal the storedintegration.internal_id. For a Page channel that stored id is the Page id, whileauthenticate()returns/me’s user id. The comparison fails with “Please refresh the channel that needs to be refreshed”. The provider’sreconnect()(which re-derives the Page token) is only called by the engine whenroot_internal_idis set, and Facebook channels have no root. Practically: reconnect by creating a new connection, then delete the old channel. refresh_tokenis not a refresh token.authenticate()stores the long-lived access token in bothaccess_tokenandrefresh_token. Nothing rotates it. Do not treat it as a Metafb_exchange_tokeninput.- 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 ignoresfieldshere; the code readsaccess_tokenonly. - 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()andfetch_page_information()ignore errors from/me/businesses,owned_pagesandclient_pagesand 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_tokenmatters. A Page listed through a business the user has no role on comes back withoutaccess_token. The provider detects this and returns a specific message instead of a generic “not found”.max_concurrent_job = 500is 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 limitor1366046/1390008throttling. The code maps1390008to a retry (“You are posting too fast, please slow down”).- Feed posts are not pending.
post_pending()forpost_type != storycallspost_non_story(), which performs the feed call and returnsPostStatus::Success.supports_pending = trueexists only for the story flow. The engine’spost()compatibility path still loops on the story pending data; ordinary posts return immediately. - Story handshake. Story publishing uses
attempting(the item index) andconfirmed.check_post_status()waits for every not-yet-published video to reportupload_completeorready, 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()publishesitems[published_count], increments the counter and returns Pending if more items remain. Each story counts as a separate publication and has its ownpost_id. - Video processing is polled by
GET /{video_id}?fields=status. Onlyupload_completeandreadycount as done;errorbecomes “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_urlmakes 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 anerrorobject;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.
- feed post id
- Percentage change is fake. Page analytics hard-code
percentage_change: 5.0; post analytics hard-code0.0. Do not present these as real period-over-period changes. - Insight metric names are version-sensitive.
page_total_media_view_uniqueandpage_media_viewhave 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_idretry.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() <= 130matches JavaScript string length, not Unicode scalar count. An emoji counts as 2. - Error mapping. Dozens of Meta subcodes are translated:
1366046photos4 MB / not JPG-PNG,
1390008posting too fast,1346003abusive content,1404006security check,1404078Page publishing authorization,1366051duplicate photos,1609008Facebook.com links,4854002identity confirmation, subcode459security check, subcode492lost Page role, subcode33deleted Page/post. A bare401is treated as an unknown error, not a refresh. pages()exposes Page access tokens. Thepagesfunction returns the rawaccess_tokenfor every Page, andtriggerToolsends that array to the MCP client. Treat the tool output as secret; do not log it or paste it into shared transcripts. ThechannelSetupToolresponse 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 = 500should be lowered for media-heavy workloads. The provider does not readX-Business-Use-Case-UsageorX-App-Usageheaders 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
- Meta app creation: https://developers.facebook.com/apps/creation/
- Facebook Login for Business: https://developers.facebook.com/docs/facebook-login/facebook-login-for-business
- Permissions reference: https://developers.facebook.com/docs/permissions/reference
- App modes (Development vs Live): https://developers.facebook.com/docs/development/build-and-test/app-modes
- Access levels (Standard vs Advanced): https://developers.facebook.com/docs/graph-api/overview/access-levels
- App Review: https://developers.facebook.com/docs/app-review
- Access tokens (user, Page, long-lived): https://developers.facebook.com/docs/facebook-login/guides/access-tokens
- Pages API posts: https://developers.facebook.com/docs/pages-api/posts
- Page Stories API: https://developers.facebook.com/docs/page-stories-api
- Page Insights: https://developers.facebook.com/docs/graph-api/reference/page/insights
- Postiz Facebook setup: https://docs.postiz.com/providers/facebook
- Postiz Facebook settings: https://docs.postiz.com/public-api/providers/facebook
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, notfarcaster.
Overview
| Property | Value |
|---|---|
| Identifier | wrapcast |
| Display name | Farcaster |
| Connection mode | signer (server-created Neynar signer, human approval) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Advertised 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 publishing | No. supports_pending = false; post() calls Neynar and returns Posted |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 800 characters, hard-coded |
| Web3 flag | is_web3 = true |
| Scopes | None |
| Redirect URI | None; 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
NEYNAR_CLIENT_ID | Yes | — | 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_FID | Yes | — | 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_MNEMONIC | Yes | — | BIP-39 mnemonic of the app account’s custody address. Signs the EIP-712 SignedKeyRequest. Secret. |
NEYNAR_SECRET_KEY | Yes | — | Neynar API key, sent in the api_key header by the Rust code |
NEYNAR_BASE_URL | No | https://api.neynar.com | Neynar API origin; trailing slash trimmed |
NEYNAR_SPONSOR_SIGNERS | No | unset | When exactly "true", adds sponsor: {sponsored_by_neynar: true} to the signed-key registration. Neynar charges the developer’s credits. |
JWT_SECRET | No direct use | empty | Not 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
- Sign in at https://dev.neynar.com.
- 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. - 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.
- Put the values in the environment:
NEYNAR_CLIENT_ID="…"
NEYNAR_APP_FID="12345"
NEYNAR_APP_MNEMONIC="word word word …"
NEYNAR_SECRET_KEY="…"
- Restart
rustiz serve,rustiz workerandrustiz 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=trueand 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:
- Connect once with a test Farcaster account and keep the resulting channel.
- Do not delete/reconnect repeatedly: each connect attempt creates a new signer and can cost another approval.
- 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:
- Requires
NEYNAR_SECRET_KEY. - Calls
GET /v2/farcaster/signer?signer_uuid=…. - If the status is
approvedand the response has anfid, builds the same base64 user payload and connects immediately. - 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()):
POST /v2/farcaster/signerwith{}→signer_uuid,public_key,status: generated.deadline = now + 24 hours.- Sign the EIP-712
SignedKeyRequestwith the app mnemonic. POST /v2/farcaster/signer/signed_keywith:
{
"signer_uuid": "…",
"app_fid": 12345,
"deadline": 1760000000,
"signature": "0x…",
"sponsor": { "sponsored_by_neynar": true }
}
sponsor is present only when NEYNAR_SPONSOR_SIGNERS=true.
- Read
signer_approval_urlandsigner_uuid; buildqrCode = qr_data_url(approval_url)(PNG data URL, 4 px modules, 4-module margin). - Store
{signerUuid, approvalUrl}in theoauth_statesrow ascode_verifier,provider="wrapcast", TTL 30 minutes.
When the human approves in the Farcaster app, the server polls:
GET /v2/farcaster/signer?signer_uuid=….- If
status != "approved", return pending (or an error ifrevoked). - If approved, read
fid. GET /v2/farcaster/user/bulk?fids=<fid>to getusername,display_name,pfp_url.- Build a base64 JSON payload:
{
"signer_uuid": "…",
"fid": 12345,
"username": "alice",
"display_name": "Alice",
"pfp_url": "https://…"
}
authenticate()decodes it and stores:id= FIDaccess_token=signer_uuidrefresh_token=""expires_in= 200 yearsusername= Farcaster usernamename= display namepicture= 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
- If
subredditis present and notnull, it must be an array. - Each entry must be an object.
- Each entry must have a
valuethat is an object. value.idmust be a string.- 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()readsposts.first().settings["subreddit"].null, a non-array, or an empty array → one cast with nochannel_id(timeline).- A non-empty array → one cast per
value.id, each withchannel_id. post_idandrelease_urlare 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.
triggerTool: channel search
functions() declares one function:
| Function | Params | Returns | Notes |
|---|---|---|---|
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), yetinfo.supports_commentsis never set totrue. The engine truncates a multi-entry post to the first entry, and MCPvalidatePostToolreports “Farcaster does not support thread replies”. If you need threads, setinfo.supports_comments = trueinFarcaster::new(); the code path already exists. post()only reads the firstPostDetails. 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 becausesupports_comments = false.NEYNAR_CLIENT_IDis dead weight in the headless flow. It is required byprovider_status()and only used bygenerate_auth_url(), which returns"{client_id}||{state}"— not a URL and never called byconnect.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 asx-api-key. Neynar has historically acceptedapi_key; if every call returns 401, change the header tox-api-keyinneynar_get()/neynar_post(). - Trailing slashes. The code uses
/v2/farcaster/signerand/v2/farcaster/signer/signed_keywithout 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/signerand 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 usecustomFields.signerUuid. Neynar explicitly warns to reuse signers while testing. - Approval TTL is 30 minutes and is not refreshed.
poll_farcaster()does not callput_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 readssigner_uuidandsigner_approval_urlwithunwrap_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 whenstatus == "approved"and the response containsfid. If Neynar reportsapprovedwithoutfid(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()detectsstatus == "revoked", deletes the pending state and returns an actionable error. ThecustomFieldsshortcut 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 = 3is 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 withexpires_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 approvedsignerUuid). - No idempotency.
POST /v2/farcaster/castis called without Neynar’sidemfield. 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.
embedsare URLs, so the media must be hosted publicly before publishing. This differs from Bluesky/Mastodon, where Rustiz uploads bytes. subredditsis 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.rsspecial-caseswrapcastbefore the generic web3 rejection, so it is supported. Other web3 providers are not.- No analytics / mentions.
supports_analytics,supports_post_analyticsandsupports_mentionsare allfalse; there are no overrides. - Release URL.
cast_urls()buildshttps://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
- Neynar dashboard: https://dev.neynar.com
- Managed signers overview: https://docs.neynar.com/docs/write-to-farcaster-with-neynar-managed-signers
- Create signer: https://docs.neynar.com/reference/create-signer
- Register signed key: https://docs.neynar.com/reference/register-signed-key
- Signer status: https://docs.neynar.com/reference/lookup-signer
- Publish cast: https://docs.neynar.com/reference/publish-cast
- Farcaster casts guide: https://docs.farcaster.xyz/developers/guides/writing/casts
- Postiz Farcaster setup: https://docs.postiz.com/self-host/providers/farcaster
- Postiz Warpcast settings: https://docs.postiz.com/public-api/providers/warpcast
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 QPMandPERMISSION_DENIEDuntil Google approves the Cloud project for the Business Profile APIs. Approval requires a verified Business Profile active for 60+ days and a website.
Overview
| Property | Value |
|---|---|
| Identifier | gmb |
| Display name | Google My Business |
| Connection mode | oauth, then channelSetupTool (location selection) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | No. supports_pending = false; localPosts.create is synchronous |
| Analytics | Yes. supports_analytics = true; location-level Performance API metrics |
| Post analytics | Flag is true, implementation always returns []. Local posts have no per-post metrics; the API is location-level |
| Mentions | No. supports_mentions = false |
is_between_steps | true |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 1500 characters (GBP local-post summary limit) |
| API | My Business API v4 (mybusiness.googleapis.com), Account Management v1, Business Information v1, Business Profile Performance v1 |
| OAuth scopes | https://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../business.manage |
| Token lifetime | Google 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
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
GOOGLE_GMB_CLIENT_ID | One of the pair | none | Preferred GMB OAuth client id. |
GOOGLE_GMB_CLIENT_SECRET | One of the pair | none | Preferred GMB OAuth client secret. |
YOUTUBE_CLIENT_ID | One of the pair | none | Fallback client id (shared Google app). |
YOUTUBE_CLIENT_SECRET | One of the pair | none | Fallback client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI. Trailing / stripped. |
GOOGLE_AUTH_BASE_URL | No | https://accounts.google.com | /o/oauth2/v2/auth. |
GOOGLE_OAUTH_BASE_URL | No | https://oauth2.googleapis.com | /token, /tokeninfo. |
GOOGLE_BASE_URL | No | https://www.googleapis.com | /oauth2/v2/userinfo. |
GMB_ACCOUNTS_BASE_URL | No | https://mybusinessaccountmanagement.googleapis.com | /v1/accounts. |
GMB_INFO_BASE_URL | No | https://mybusinessbusinessinformation.googleapis.com | /v1/{account}/locations, /v1/{location}, /v1/{location}/media. |
GMB_BASE_URL | No | https://mybusiness.googleapis.com | v4 local posts: /v4/{id}/localPosts, where {id} is the full accounts/<n>/locations/<m> resource name. |
GMB_PERFORMANCE_BASE_URL | No | https://businessprofileperformance.googleapis.com | /v1/locations/{id}:fetchMultiDailyMetricsTimeSeries. |
GMB_WEB_BASE_URL | No | https://business.google.com | Fallback 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
- Open https://console.cloud.google.com/ → Create project.
- Copy the Project number from the project dashboard.
- 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.
- 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.
- 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:
| API | Used for |
|---|---|
| Google My Business API (v4) | localPosts.create — only appears in the library after the access request is approved |
| My Business Account Management API | GET /v1/accounts |
| My Business Business Information API | GET /v1/{account}/locations, GET /v1/{location}, GET /v1/{location}/media |
| Business Profile Performance API | fetchMultiDailyMetricsTimeSeries 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).
4. OAuth consent screen
Google Auth Platform → Audience:
- User type External (or Internal for a Workspace-only deployment).
- App name, support email, developer contact, logo, authorized domains.
- Add the scopes
.../auth/userinfo.profile,.../auth/userinfo.email,.../auth/business.manage. - Add every Google account that will connect as a test user while in Testing.
- Publish the app for production.
business.manageis a sensitive Business Profile scope and will require OAuth verification (privacy policy, homepage, demo video, scope justification).
5. Create the OAuth client
-
Google Auth Platform → Clients → Create client.
-
Type Web application.
-
Authorized redirect URIs:
{FRONTEND_URL}/integrations/social/gmbExample
https://social.example.com/integrations/social/gmb.http://localhost:5000/integrations/social/gmbis allowed for local testing. No trailing slash beyond whatFRONTEND_URLproduces (it is trimmed). -
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
localPostsendpoint still works in 2026 even though accounts, locations and performance moved to v1. The provider depends on it.
Production app review
- 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. - Business Profile API access must already be approved (300 QPM). The approval is tied to the Cloud project, not the OAuth client.
- 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_EXHAUSTEDfor gRPC-style clients), which the provider maps to a “Rate limit exceeded”BadBody.
business.managecan be revoked by the user at any time at https://myaccount.google.com/permissions; the next call fails withUNAUTHENTICATED/invalid_grantand 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:
locationNameis required byfetch_page_information(missing →"locationName is required to fetch the location");idbecomes the channel’sinternal_idand is the{id}in/v4/{id}/localPosts;accountNameis 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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
pages | none | array 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.
| Field | Type | Behavior |
|---|---|---|
topicType | enum STANDARD, EVENT, OFFER | Defaults to STANDARD at publish time when missing/empty. |
callToActionType | enum NONE, BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, GET_OFFER, CALL | CTA button. NONE disables it. The CTA is only sent when both a type and a non-empty callToActionUrl are present — see the CALL gotcha. |
callToActionUrl | string | URL opened by the CTA. Validated as an absolute http(s) URL with a host when non-empty. |
eventTitle | string | Required for EVENT. |
eventStartDate | string YYYY-MM-DD | Event start; invalid/missing → today (local date). |
eventEndDate | string YYYY-MM-DD | Event end; invalid/missing → today. |
eventStartTime | string HH:MM | Optional; parsed into {hours, minutes, seconds: 0, nanos: 0}. |
eventEndTime | string HH:MM | Optional. |
offerCouponCode | string | Offer coupon code. |
offerRedeemUrl | string | Offer redemption URL; validated as an absolute http(s) URL when non-empty. |
offerTerms | string | Offer 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 inSTANDARD/EVENT/OFFER.- If
topicType == "EVENT",eventTitlemust be a JSON string (an empty string passes this check;check_validitycatches it later). callToActionType, if present, must be one of the eight enum values.callToActionUrlandofferRedeemUrl, when present and non-empty, must parse ashttp/httpswith 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/missingeventTitle→"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
mediaarray with one item:{"mediaFormat": "PHOTO"|"VIDEO", "sourceUrl": <path>}. Validation only rejects paths containingmp4, so a.mov/.webmvideo path would pass and be sent asVIDEO; Google does not accept video local posts, so keep media to JPEG/PNG.sourceUrlmust 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/jsonwhere
{id}is the fullaccounts/<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": "..." } }eventis emitted only forEVENTwith a non-empty title;offeronly forOFFER. Empty strings are omitted rather than sent as blanks. -
languageCodeis hard-coded"en". There is no setting for it; thesummarytext 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
searchUrlwhen present, otherwise{GMB_WEB_BASE_URL}/locations/<last path segment of id>.
-
No pending state.
supports_pending = false; the post isSuccessas soon as Google returns a resource name. There is no media-processing poll and nofinalize_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 metric | Label |
|---|---|
WEBSITE_CLICKS | Website Clicks |
CALL_CLICKS | Phone Calls |
BUSINESS_DIRECTION_REQUESTS | Direction Requests |
BUSINESS_IMPRESSIONS_DESKTOP_MAPS | Desktop Map Views |
BUSINESS_IMPRESSIONS_MOBILE_MAPS | Mobile 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()prefersGOOGLE_GMB_CLIENT_IDand falls back toYOUTUBE_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_statususes 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.manageis 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_informationrequireslocationName. The common mistake is to pass only{"page": ...}(LinkedIn style) or only{"id": ...}. GMB needsid,accountNameandlocationName, exactly as returned bypages.- The
internal_idis the full resource name.accounts/123/locations/456is 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. CALLCTAs are effectively broken.callToActionis only emitted when bothcallToActionTypeand a non-emptycallToActionUrlexist. A phone-call CTA in GBP does not take a URL, so aCALLsetting without a URL is silently dropped, and with a URL Google may reject or ignore it. UseLEARN_MOREwith a URL for reliable behavior.- Dates default to today, not to the event fields. An invalid/missing
eventStartDate/eventEndDatebecomes today; anEVENTwith only a title can therefore be created as a zero-length event today. Always send both dates. - Times are parsed leniently.
eventStartTime/eventEndTimesplit on:; non-numeric parts become0."9:5"becomes 09:05,"abc"becomes 00:00. There is no timezone field; GBP interprets the schedule in the location’s timezone. languageCodeis fixed toen. 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.createis live on Search/Maps. Tests must account for that. REJECTEDis 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 thebodyin the failure details for the real reason. The body is preserved, except for token calls wherewithout_request_bodyscrubs it.PERMISSION_DENIEDtriggers a token refresh.handle_errorsmapsPERMISSION_DENIEDtoHandledError::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
Unauthorizedmessage 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_bodyreplaces the recorded request body with{}forBadBody/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 callsuserinfoand returns the account id/name/picture. Only the tokens and expiry are stored, so the locationinternal_idis preserved; do not treat the refresh result’s id as a location id.expires_incan be 0. If Google returns neitherexpires_innorexpiry_date, the provider stores0. Sincerefresh_cronis false, this does not cause a refresh loop; it only means the token’s expiry is unknown.supports_post_analytics = trueis 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; aRESOURCE_EXHAUSTEDbody 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. localPostsis 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
- Business Profile API prerequisites (verified profile 60+ days, website, project number, access request, 0 vs 300 QPM): https://developers.google.com/my-business/content/prereqs
- Business Profile API quota limits (300 QPM, 10 edits/min per profile): https://developers.google.com/my-business/content/limits
- Basic setup / enable the APIs: https://developers.google.com/my-business/content/basic-setup
- Local posts v4 reference: https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts
- Business Profile Performance API: https://developers.google.com/my-business/reference/performance/rest
- OAuth app audience and Testing-mode limits (100 users, 7-day expiry): https://support.google.com/cloud/answer/15549945
- Managing OAuth clients and redirect URI rules: https://support.google.com/cloud/answer/15549257
- Business Profile API access request form: https://support.google.com/business/contact/api_default
- Postiz Google My Business guide: https://docs.postiz.com/self-host/providers/google-my-business
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | hashnode |
| Display name | Hashnode |
| Connection mode | customFields (Personal Access Token) |
| Editor | markdown |
| Channel kind | social (article content) |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published |
| Pending / async publishing | No. supports_pending = false; success is PostStatus::Completed |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 10000 characters for contentMarkdown |
| API | GraphQL, 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
HASHNODE_BASE_URL | No | https://gql-beta.hashnode.com | Overrides 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_DOMAINS | No | unset | If set, main_image.path must contain this substring. |
NEXT_PUBLIC_BACKEND_URL | No | "" | Prefix used for a relative main_image.path. See the cover-image bug below. |
NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY | No | "" | 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.
-
Sign in to Hashnode.
-
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-skilllistspublishPostand publication-scoped reads as Pro-gated and returnsFORBIDDENwith the messagePublication does not have an active Pro planwhen the plan is missing. Upgrade under the publication dashboard’s Billing page. -
Open Account Settings → Developer → API tokens. The direct URL is https://hashnode.com/settings/developer (it redirects through login).
-
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. -
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
Bearerprefix. 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/EDITORcan publish; aCONTRIBUTORcan create drafts but publishing may require review. - The target publication must be Pro for
publishPostand 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.
publishPostpublishes immediately; Rustiz has no draft mode. To test a token without publishing, use a manualcreateDraftmutation or amequery.- The static tag catalogue can be inspected without any API call via the
tags/tagsListprovider 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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
apiKey | password | /^.{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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
tags | none | [{"value": "<objectID>", "label": "<name>"}] | Serves the static 1000-entry catalogue in hashnode/tags.rs. No API call. |
tagsList | none | [{"name": "...", "logo": "...", "slug": "...", "objectID": "..."}] | Raw catalogue entries. logo is omitted when the catalogue has no logo. |
publications | none | [{"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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | Yes | minLength: 6; checked as at least 6 Unicode scalar values. |
subtitle | string | No | minLength: 2 when present. Sent as the GraphQL subtitle input. |
main_image | object | No | Cover image. Properties: id (string), path (string), alt (string), thumbnail (string URL). id and path are required by the schema. |
canonical | string | No | Sent as originalArticleURL. If it contains (post:, URL validation is skipped. |
publication | string | Yes | Publication id. Required by schema and validator. Get it from the publications tool. |
tags | array | Yes | minItems: 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
titlemust exist, be a string and have at least 6 characters.subtitle, if present and notnull, must be a string of at least 2 characters.publicationmust exist and be a string.canonical, if present and a string, must be a valid URL unless it contains(post:.tagsmust exist and be an array with at least one item. Each item must be an object with a stringvalueand a stringlabel. Unlike the editor’s tag picker, the validator does not restrict the tag to the static catalogue.main_image, if present and notnull:- must be an object;
idmust be a string;pathmust be a string;- if
RESTRICT_UPLOAD_DOMAINSis set,pathmust contain it; - the path (query string stripped) must end in
.png,.jpg,.jpeg,.gif,.webpor.mp4(case-insensitive). altandthumbnailare in the schema but are not validated here.
Media requirements
- Only
main_image.pathis used ascoverImage; the postmedia/attachmentsarray is ignored. - The cover must be a public URL (or a path the prefix logic can turn into one).
- Only the path is sent.
altandthumbnailare 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
Authorizationtoken, noBearer.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 usesAuthorization: Bearer. If a future API change rejects raw tokens, this is the first place to look. - GraphQL errors arrive with HTTP 200.
post()checksresponse["errors"]andresponse["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 isHashnode 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 concatenatespathwith no separator. WithNEXT_PUBLIC_BACKEND_URL=https://back.exampleandNEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY=uploads, the pathuploads/cover.pngbecomeshttps://back.example/uploadsuploads/cover.png(the test asserts exactly this). Use an absolutemain_image.pathstarting withhttpto avoid the bug, or set the static directory to an empty string. - Unknown tag objectIDs become
slug: null. The static catalogue mapsvalue(objectID) toslug. If the id is not intags.rs, the mutation containstags: [{slug: null}], which Hashnode rejects. Always use ids returned by thetagsfunction, not arbitrary tag names. - The tag catalogue is frozen at 1000 entries. New Hashnode tags will not
be selectable until
hashnode/tags.rsis regenerated.tagsListis the raw catalogue;tagsis 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. publicationis 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.
authenticatestores a 100-yearexpires_inandrefresh_token: Some("");refresh_token()returns an empty access token. Revoked PATs require reconnecting the channel. - No
x-hashnode-clientheader. Hashnode’s official skill sendsx-hashnode-client: gql-skillfor telemetry. It is not required for authentication; Rustiz omits it.
References
- Hashnode GraphQL endpoint: https://gql-beta.hashnode.com
- Hashnode developer settings: https://hashnode.com/settings/developer
- Official Hashnode GraphQL agent skill (endpoint, auth, Pro gate, limits): https://github.com/Hashnode/gql-skill
- Postiz provider settings: https://docs.postiz.com/public-api/providers/hashnode
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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:
InstagramCoreis also used byinstagram-standalonewithgraph.instagram.com
Overview
| Property | Value |
|---|---|
| Identifier | instagram |
| Display name | Instagram\n(Facebook Business) |
| Connection mode | oauth (Facebook Login for Business) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries are posted as POST /{ig-media-id}/comments |
| Pending / async publishing | Yes. supports_pending = true; containers are created, polled to FINISHED, then media_publish |
| Analytics | Yes. supports_analytics = true; account insights |
| Post analytics | Yes. supports_post_analytics = true; media insights |
| Mentions | No. supports_mentions = false (the platform supports mentions, but the provider does not implement them) |
is_between_steps | true — the OAuth callback creates an unfinished channel; an Instagram account must be selected with channelSetupTool |
convert_to_jpeg | false — images are not converted, despite Instagram accepting only JPEG |
max_concurrent_job | 400 |
Character limit (max_length) | 2,200 characters |
| Graph API version | v25.0 |
| API hosts | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
FACEBOOK_APP_ID | Yes | — | The Meta app ID, not the Instagram app ID. Shared with facebook. |
FACEBOOK_APP_SECRET | Yes | — | The Meta app secret. Shared with facebook. |
FACEBOOK_BASE_URL | No | https://graph.facebook.com | Graph API origin for containers, publishing, insights and page discovery. |
FACEBOOK_WEB_BASE_URL | No | https://www.facebook.com | Origin of the /v25.0/dialog/oauth login dialog. |
INSTAGRAM_WEB_BASE_URL | No | https://www.instagram.com | Defined by the shared core for the standalone provider; not used by this provider’s login flow. |
INSTAGRAM_API_BASE_URL | No | https://api.instagram.com | Defined by the shared core; not used by the Facebook Business flow. |
INSTAGRAM_GRAPH_BASE_URL | No | https://graph.instagram.com | Used only by instagram-standalone. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global 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
- Go to https://developers.facebook.com/apps/creation/.
- Choose Other → Business and create the app under a Business portfolio. Public apps need Business Verification.
- Add the product Facebook Login for Business.
- Add the product Instagram and choose Instagram API setup with Facebook login.
- 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.
| Scope | Why Rustiz needs it |
|---|---|
instagram_basic | Read the Instagram professional account profile/media |
pages_show_list | Discover Facebook Pages and their linked Instagram accounts |
pages_read_engagement | Read Page metadata and linked IG account |
business_management | Discover Pages through Business Manager (owned_pages, client_pages) |
instagram_content_publish | Create containers and media_publish |
instagram_manage_comments | Post comments/replies |
instagram_manage_insights | Account 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
-
List the Instagram accounts reachable through the connected Pages:
{ "integrationId": "<unfinished channel id>", "methodName": "pages", "dataSchema": [] }instagramexposes two functions:Function Description Input 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"}pagesreturns:[ { "pageId": "1234567890", "id": "17841400000000000", "name": "My Instagram", "picture": { "data": { "url": "https://…" } } } ]pageIdis the Facebook Page id;idis the Instagram professional account id used for publishing. -
Choose an account and call:
{ "integrationId": "<unfinished channel id>", "data": { "id": "17841400000000000", "pageId": "1234567890" } }channelSetupToolpassesdatastraight toInstagram::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 clearsinBetweenSteps.
- reads
-
Verify with
integrationList: the channel name is the Instagram name,profileis the username andinBetweenStepsisfalse.
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
customFieldsroutes toconnect_custom()and is treated as an OAuth code. Never send it toinstagram.externalUrlis 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
}
| Field | Required | Behaviour |
|---|---|---|
post_type | Yes | post = feed image/video/carousel; story = story media. A story with multiple media publishes each as a separate story |
is_trial_reel | No | Only for a single video. Adds trial_params={"graduation_strategy": …}. The account must support Trial Reels |
graduation_strategy | No | Defaults to MANUAL when is_trial_reel is true |
collaborators | No | Array of {label}. A leading @ is stripped. Sent as a JSON array; not allowed on stories or carousel children; allowed on the carousel parent |
audio | No | Audio 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()
settingsmust be an object.post_typemust be exactlypostorstory; it is not defaulted.is_trial_reel, when non-null, must be boolean or string (the string is parsed byas_bool).graduation_strategy, when non-null, must beMANUALorSS_PERFORMANCE.collaborators, when non-null, must be an array; each item must be an object with a non-empty stringlabel.audio, when non-null:- must be an object;
idmust be a string (an empty string is accepted and means “no audio”);title,artist,imagemust be strings when present;audio_volumeandvideo_volumemust be numbers in[0, 100].
graduation_strategyis validated even whenis_trial_reelis false.- No maximum caption length is checked here;
max_lengthhandles the engine side.
check_validity()
The shared instagram_check_validity(posts, settings, carousel_checks = true)
enforces:
- At least one media item in the first post.
- At most 10 media items in the first post: “Instagram carousel only supports up to 10 media attachments”.
- If
is_trial_reel:- only one media;
- that media must be a video (
pathcontains.mp4).
- Alt text on any item must be ≤ 1,000 UTF-16 code units.
- 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_jpegis 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):
| Media | Rule |
|---|---|
| Feed image | JPEG only, max 8 MB, aspect ratio 4:5 to 1.91:1, width 320–1,440 px (Meta scales outside that range) |
| Carousel | 2–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 video | Published 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 image | JPEG, ≤ 8 MB, 9:16 recommended |
| Story video | MP4/MOV, 3–60 s, ≤ 100 MB, 9:16 recommended |
| Alt text | Images only (single or carousel item), max 1,000 characters, ignored on videos and stories |
| Collaborators | Up to 3 on a feed post/Reel; account must be public and allow invites |
| Audio | Single-video Reel only; not on stories, images or carousels |
How Rustiz builds each container:
- Single image:
POST /{ig-id}/media?image_url=…&caption=…(nomedia_type, so Instagram defaults toIMAGE). - Single video, feed:
video_url=…&media_type=REELS, pluscover_urlwhen a thumbnail is provided, otherwisethumb_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=VIDEOfor videos,is_carousel_item=true, no caption. - Carousel parent: created later in
finalize_post()withcaption,media_type=CAROUSEL,children=<ids>, optionalcollaborators. - Multiple stories: each media is its own
STORIEScontainer; 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:
- The first entry creates the containers and publishes the feed post/Reel/story.
- Each later entry calls
POST /{post-id}/comments?message=…. comment()ignoreslast_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.- 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_tokensplits 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 emptypage.access_token; it builds"___<user token>". Publishing then fails as the Page. The Facebook provider does check for this case. - Empty
pageslist can hide a permission problem.InstagramCore::pages()fetches each Page’s Instagram profile separately and useslet 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
channelSetupToolruns,internal_idis the user id, not an Instagram account id, and publishing targets the wrong object. - JPEG-only means JPEG-only.
convert_to_jpegisfalse, and the provider never re-encodes images. Upload PNG/WebP and Instagram returns2207005(“Unsupported image format”) or2207004(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 Mediareference 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_errorsmaps2207042to “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 withexpires_in: 0;refresh_cronisfalse. The long-lived user token is stored with a hard-coded 59-day expiry. When it expires, a190orREVOKED_ACCESS_TOKENtriggers the engine’s refresh path, which fails and marks the channelrefreshNeeded; the user must reconnect. - Reconnect id mismatch. After setup,
internal_idis the Instagram id. If the OAuth callback is called with?refresh=<ig id>,save()compares it toauthenticate()’s/meid, which is the Facebook user id, and rejects with “Please refresh the channel that needs to be refreshed”. Reconnect by creating a new channel. Thereconnect()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’sstatus_codeonce and returns Pending onIN_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 blockingpost()compatibility path sleeps 30 s. ERROR/EXPIREDcontainers.container_status()maps the status text throughinstagram_handle_errors(). ADisconnectclassification becomes a disconnect; everything else becomes a bad-body error with the platform message, falling back to “Instagram could not process the media”.media_publishblocked but live. Instagram sometimes returns2207051(“action is blocked”) after actually publishing the container.finalize_post()probes the container; if it reportsPUBLISHED, it reads the newest/{ig-id}/media?fields=id&limit=1and 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 alreadyPUBLISHED. The finalpost_idis 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.2207018is mapped to a collaborator-specific message. - Audio is silently dropped outside its supported shape.
audio_configurationis only added whendomain == 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) and2207078(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,reachwithperiod=day, thenmetric_type=total_value&metric=likes,views,comments,shares,saves,replies. Post analytics requestsviews,reach,saved,likes,comments,sharesand readsvalues[0].value. Every error is swallowed and returns[]. Percentage change is hard-coded to5.0for account metrics and0.0for post metrics. - Insight metric names drift.
savedvssaves,sharesavailability, andviewson 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 × impressionsper rolling 24 hours per app/user pair. Comments have their own limits.max_concurrent_job = 400is far above what the publishing cap allows in a day; it controls worker concurrency, not the daily quota. - Error mapping highlights.
2207050restricted user;2207003media download timeout;2207020expired media;2207026unsupported video format;2207028carousel validation;2207010caption too long;2207004image too large;2207005unsupported image;2207009/36003aspect ratio;2207042daily limit;2207001spam;1772107comment rejected but post published;2207082audio on a silent video;2207085video processing;2207077download 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.0is 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
- Meta app creation: https://developers.facebook.com/apps/creation/
- Instagram Platform overview (three setups): https://developers.facebook.com/docs/instagram-platform/overview
- Instagram API with Facebook Login: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-facebook-login
- Content publishing (containers, carousel, Reels, stories): https://developers.facebook.com/docs/instagram-platform/content-publishing
- IG User Media reference (image/video specs): https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media
- Permissions reference: https://developers.facebook.com/docs/permissions/reference
- App modes: https://developers.facebook.com/docs/development/build-and-test/app-modes
- Access levels: https://developers.facebook.com/docs/graph-api/overview/access-levels
- App Review: https://developers.facebook.com/docs/app-review
- Postiz Instagram setup: https://docs.postiz.com/providers/instagram
- Postiz Instagram settings: https://docs.postiz.com/public-api/providers/instagram
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.
| Aspect | instagram | instagram-standalone |
|---|---|---|
| App credentials | FACEBOOK_APP_ID / FACEBOOK_APP_SECRET | INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET |
| Login product | Facebook Login for Business + Instagram API setup with Facebook login | Business Login for Instagram + API setup with Instagram login |
| Facebook Page | Required | Not required |
| API host | graph.facebook.com | graph.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_steps | true (select an IG account) | false |
refresh_cron | false (no automatic refresh) | true (ig_refresh_token) |
| Audio in Reels | Yes (Facebook Graph only) | Silently ignored |
| Local carousel cap | 10 | None (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
| Property | Value |
|---|---|
| Identifier | instagram-standalone |
| Display name | Instagram\n(Standalone) |
| Connection mode | oauth (Business Login for Instagram) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries become comments on the root media |
| Pending / async publishing | Yes. supports_pending = true; containers → poll → media_publish |
| Analytics | Yes. supports_analytics = true |
| Post analytics | Yes. supports_post_analytics = true |
| Mentions | No. supports_mentions = false |
is_between_steps | false — the account is stored directly, no page/account selection step |
convert_to_jpeg | false |
max_concurrent_job | 200 (“standalone has stricter limits”) |
Character limit (max_length) | 2,200 |
| Graph API version | v25.0 |
| API hosts | https://graph.instagram.com (INSTAGRAM_GRAPH_BASE_URL), https://api.instagram.com (INSTAGRAM_API_BASE_URL), https://www.instagram.com (INSTAGRAM_WEB_BASE_URL) |
| Token refresh | Yes. 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
INSTAGRAM_APP_ID | Yes | — | The Instagram App ID from App Dashboard → Instagram → API setup with Instagram login → Business login settings. Not the Meta/Facebook app ID. |
INSTAGRAM_APP_SECRET | Yes | — | The Instagram App Secret from the same panel (32 characters). |
INSTAGRAM_GRAPH_BASE_URL | No | https://graph.instagram.com | Host for /v25.0/me, /access_token, /refresh_access_token and all publishing calls. |
INSTAGRAM_API_BASE_URL | No | https://api.instagram.com | Host for the code → short-lived-token exchange (POST /oauth/access_token). |
INSTAGRAM_WEB_BASE_URL | No | https://www.instagram.com | Host of the authorization window (/oauth/authorize). |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI, wrapped in redirectmeto.com when it is not HTTPS. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global 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
- Go to https://developers.facebook.com/apps/creation/.
- Choose Other → Business (a Business portfolio is recommended).
- Add the product Instagram.
- In the Instagram product, choose API setup with Instagram login.
- 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_URL | Registered redirect URI |
|---|---|
https://social.example.com | https://social.example.com/integrations/social/instagram-standalone |
http://localhost:5000 | https://redirectmeto.com/http://localhost:5000/integrations/social/instagram-standalone |
http://localhost:4200 | https://redirectmeto.com/http://localhost:4200/integrations/social/instagram-standalone |
http://127.0.0.1:5000 | https://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. AnyFRONTEND_URLcontaining the lettershttpsskips the wrapper. This is almost always what you want forhttps://…, but it also matches odd hosts/paths containinghttps. - There is no trailing slash between the frontend and the path.
- If
FRONTEND_URLalready has a path prefix (https://example.com/app), the callback becomeshttps://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.
| Scope | Why Rustiz needs it |
|---|---|
instagram_business_basic | Read the profile (/me) and media |
instagram_business_content_publish | Create containers and publish |
instagram_business_manage_comments | Post comments/replies |
instagram_business_manage_insights | Account and media insights |
4. Token flow performed by Rustiz
- Authorization code →
POST https://api.instagram.com/oauth/access_tokenasmultipart/form-datawithclient_id,client_secret,grant_type=authorization_code,redirect_uri,code. - Short-lived → long-lived →
GET https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_id=…&client_secret=…&access_token=… - Profile →
GET https://graph.instagram.com/v25.0/me?fields=user_id,username,name,profile_picture_url&access_token=… - Stores
id = me.user_id,name,picture,username, andaccess_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 readsshort["access_token"]andshort["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 (readdata[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:
- App Dashboard → App roles → Roles → Testers → Add People.
- Choose Instagram Tester and enter the Instagram username.
- On the Instagram account, open Settings → Website permissions → Apps and websites → Invites and accept the invitation (https://www.instagram.com/accounts/manage_access/).
- 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
customFieldsroutes toconnect_custom()and is used as an OAuth code. Never send it.externalUrlis 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_SECRETare 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-standalonevalue when developing locally. Registering onlyhttp://localhost:…fails because Instagram requires HTTPS. - The
redirectmeto.comwrapper is decided at request time. If you changeFRONTEND_URLfromhttp://…tohttps://…, 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 underdata[0]. This is the most likely cause of a connection that fails before/meis ever called. - Refresh actually works here.
refresh_cron = true; the hourly sweep refreshes any token expiring within 2 hours viaGET 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 theinstagram_business_basicgrant to still be valid. A token not refreshed within 60 days cannot be refreshed; reconnect. - 58-day expiry.
expires_inis hard-coded to58 * 24 * 3600even though Meta says 60 days. The 2-day margin is deliberate; do not “fix” it without accounting for the sweep window. mereturnsuser_id, notid. The provider storesme.user_idas the internal id (unlike the Facebook Business provider, which stores the IG id from a Page lookup).usernameis stored as the profile.idis not requested, so a response withoutuser_idstores an empty id. The code also expects a flat object (me["user_id"]); some Meta samples wrap the user in adataarray. If the channel name/id come back empty, inspect this response shape.app_id/app_secretare read on every auth/refresh. ChangingINSTAGRAM_APP_IDorINSTAGRAM_APP_SECRETaffects 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/audioSearchhelper.functions()is not overridden, sotriggerToolcannot 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 againstgraph.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.idset 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 × impressionsper 24 hours per app/user pair.max_concurrent_job = 200is worker concurrency, not a quota. - Error mapping is shared. See the
Instagram error table:
2207004/2207005/2207009image errors,2207028carousel,2207042daily limit,2207051blocked,2207001spam,1772107comment rejection,(#200)missing Page permission, checkpoint → disconnect. The “maximum of 25 posts per day” text for2207042is 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/viewsavailability varies by media type and API version. - App Review is per-permission. Adding
instagram_business_manage_insightsto the requested scopes does not grant it; each permission needs approval for non-tester users, and the app must be Live.
References
- Meta app creation: https://developers.facebook.com/apps/creation/
- Instagram API with Instagram Login: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login
- Business Login for Instagram (authorize + token endpoints): https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login
- Content publishing: https://developers.facebook.com/docs/instagram-platform/content-publishing
- IG User Media reference: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media
- Permissions reference: https://developers.facebook.com/docs/permissions/reference
- App modes: https://developers.facebook.com/docs/development/build-and-test/app-modes
- App Review: https://developers.facebook.com/docs/app-review
- Postiz Instagram setup (standalone section): https://docs.postiz.com/providers/instagram
- Postiz Instagram settings: https://docs.postiz.com/public-api/providers/instagram
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
| Property | Value |
|---|---|
| Identifier | kick |
| Display name | Kick |
| Connection mode | oauth (authorization code grant with PKCE S256) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Advertised 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 publishing | No. supports_pending = false; post() sends and returns Posted/Success |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 500 characters, hard-coded |
| OAuth scopes | chat: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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
KICK_CLIENT_ID | Yes | — | Client id from the Kick developer settings |
KICK_SECRET | Yes | — | Client secret. The name is KICK_SECRET, not KICK_CLIENT_SECRET. |
KICK_ID_BASE_URL | No | https://id.kick.com | OAuth origin (/oauth/authorize, /oauth/token). Read through Config::base_url("KICK_ID", …). |
KICK_BASE_URL | No | https://api.kick.com | Public API origin (/public/v1/...). Read through Config::base_url("KICK", …). |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI |
JWT_SECRET | No direct use | empty | Not 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
- Sign in at https://kick.com.
- Enable two-factor authentication. Kick requires 2FA to access the developer tab.
- Review Kick’s Developer Terms and access policy at https://dev.kick.com (or the links in the developer settings).
2. Create the app
- Open Account Settings → Developer: https://kick.com/settings/developer.
- 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.
- 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_URLis trimmed of trailing slashes; register the URI without a trailing slash.- There is no
redirectmeto.comwrapper 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.1in a URL). Rustiz is not Next.js, so the workaround is not needed;127.0.0.1works 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"];
}
| Scope | Used by | Why |
|---|---|---|
chat:write | POST /public/v1/chat | Send chat messages |
user:read | GET /public/v1/users | Read the channel’s user id, name and avatar during connect |
channel:read | Nothing in this provider | Read 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 ascode_verifierduring 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:
- Set the app’s Redirect URL to
https://docs.kick.com. - Enter the client id and secret.
- Choose PKCE
SHA-256. - Select the scope the endpoint needs.
- Authorize and send the request. This is a docs playground, not a separate environment: requests hit production.
- Set the app’s Redirect URL to
- 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_inandscope. Rustiz stores the access token, refresh token andexpires_in. refresh_token()performs the refresh grant and calls/public/v1/usersagain 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;
--openopens 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. Pollrustiz channels connect-status <id>. - No
--external-urland no--fields: Kick is a normal OAuth provider.customFieldstakes precedence inconnect.rsand 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 toid→ channelid(used asbroadcaster_user_id)name→nameandusernameprofile_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>
}
typeis always"user". Kick also supports"bot", but Rustiz does not use it.broadcaster_user_idisid.trim().parse::<i64>(); a non-numeric channel id becomes JSONnull, which Kick rejects with400.contentis truncated to 500 Rustchars (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 }. Missingmessage_id→ random 10-char id. is_sent == false→PostStatus::Success(the Rust enum has noerrorstate), so a message dropped by Kick looks successful. The API’smessageerror 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_idwhen non-empty, otherwise the rootpost_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, notKICK_CLIENT_SECRET. This is the most common setup failure. Checkrequired_env()in the Rust source; the variable isKICK_SECRET.channel:readis unused. The provider only calls/public/v1/usersand/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-exchangeredirect_uriwith 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/usersresponse.- No PKCE on refresh. The refresh grant sends only
grant_type,client_id,client_secretandrefresh_token; Kick’s docs do not require a verifier or redirect URI for refresh. - No
scopecheck. Granted scopes are never compared with the requested list. A deniedchat:writeis discovered only when posting fails. - No error classification.
handle_errors()returnsNone; 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: falseis hidden. A message rejected by Kick (slow mode, banned words, channel restrictions, rate limit) returns 200 withis_sent: falseand is reported asSuccess. 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 topost_idfor 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 useschars().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_idmust be numeric. The internal id comes fromget_user_info()’suser_id/id. If Kick returns a string UUID-like id,parse::<i64>()fails and the API receivesnull. 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}(orhttps://kick.com/channelifprofileis empty). Kick chat messages have no stable public permalink. - No
channel:readcalls. 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
429with a message; the docs do not publish a single numeric chat limit.max_concurrent_job = 3is a conservative cap. The generic HTTP layer retries429/5xxup 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
- Kick developer landing: https://dev.kick.com
- Kick app setup: https://docs.kick.com/getting-started/kick-apps-setup
- OAuth 2.1 flow: https://docs.kick.com/getting-started/generating-tokens-oauth2-flow
- Scopes: https://docs.kick.com/getting-started/scopes
- Chat API (500 graphemes / 2,048 bytes): https://docs.kick.com/apis/chat
- Users API: https://docs.kick.com/apis/users
- FAQs (app verification): https://docs.kick.com/apis/faqs
- Postiz Kick settings: https://docs.postiz.com/public-api/providers/kick
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
| Property | Value |
|---|---|
| Identifier | lemmy |
| Display name | Lemmy |
| Connection mode | customFields (instance URL, identifier, password) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; each comma-separated post id gets a top-level comment |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Success |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 10000 characters for the post body |
| API | Lemmy 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
JWT_SECRET | No, 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_NETWORK | No | false | Global 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.
-
Choose the Lemmy instance (for example
https://lemmy.world) and create an account there, or use an existing account. -
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.
-
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.
-
Do not enable two-factor authentication on the account. Rustiz’s login body sends only
username_or_emailandpassword; it has nototp_2fa_tokenfield, so a 2FA-enabled account cannot authenticate. -
Use the username as the
identifier, not the email address. The login endpoint accepts either, but the follow-up user lookup callsGET /api/v3/user?username={identifier}; an email in that query does not resolve to a user and the connect fails withInvalid credentials. -
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/nameandavatarfrom 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/loginGET /api/v3/user?username=...POST /api/v3/postPOST /api/v3/commentGET /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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
service | text | /^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. |
identifier | text | /^.{3,}$/ | Username (preferred) or email. At least 3 characters. |
password | password | /^.{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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
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:
subredditsreturnsidas a number, butsettings_schema()andvalidate_settings()requiresubreddit[].value.idto be a string. If you take the tool output verbatim, validation fails withid is required and must be a string. Convert the id to a string ("7", not7) 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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
subreddit | array | Yes | Historical name; it means Lemmy communities. minItems: 1; one post is created in each entry. |
subreddit[].value | object | Yes | Required wrapper. |
subreddit[].value.subreddit | string | Yes | Community name for display. minLength: 2. It is not used to resolve the community at publish time; id is. |
subreddit[].value.id | string | Yes | Numeric Lemmy community id, as a string. Get it from the subreddits tool and stringify it. |
subreddit[].value.title | string | Yes | Title of the post in that community. minLength: 2; each community can have a different title. |
subreddit[].value.url | string | No | Optional 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
subredditmust exist and be an array with at least one item.- For each item at index
i:valuemust exist;value.subredditmust be a string of at least 2 characters;value.idmust be a string (numbers fail);value.titlemust be a string of at least 2 characters;value.url, if present and a string, must be a validhttp(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_idis converted from a numeric string (or number) to a JSON number. A non-numeric id becomesnull, and Lemmy rejects the request.urlis included only when non-empty. A value that does not contain"http"getshttps://prepended.custom_thumbnailis 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.nsfwis alwaysfalse; 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(notCompleted).
Comments
comment() is supported and is how threads/replies are published:
post_idmay be a comma-separated list from a cross-community post.- For each id, it calls
POST /api/v3/commentwith{"post_id": <number>, "content": "<comment message>"}. - Comment ids and URLs are comma-joined.
last_comment_idis 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 = truemakes 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) orgif; - otherwise it returns
You can set only one picture for a cover.
- the path must contain
- Because the check is a case-sensitive substring test:
.jpeg,.webp,.PNG,.JPGare rejected even though they are valid images;.jpegfails because the code checks the typojpef, notjpeg;- a path with the letters in a query string can pass accidentally.
- It does not enforce a single item: two
.jpgfiles pass and only the first is used ascustom_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_SECRETis 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()andsubreddits()callget_jwt_and_service(), which decrypts the password and performs a fresh/api/v3/user/login. The JWT stored duringauthenticate()is effectively unused (the_access_tokenargument 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 subsequentGET /api/v3/user?username={identifier}does not resolve an email. A user who enters an email can getInvalid credentialseven with the right password. - 2FA is unsupported. There is no
totp_2fa_tokenin 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
subredditstool returns a numeric id; validation requires a string. This is a real round-trip bug, not a documentation nuance. subredditsreturnstitletwice.titleandnameare bothcommunity.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_thumbnailand is not uploaded. nsfwis 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_idis ignored, so a multi-part thread becomes several top-level comments rather than replies to one another. - Status is
Success, notCompleted. This differs from the article providers and Dribbble. The engine treatsSuccessas a published state; it is not a pending response. - Error mapping is instance-specific.
rate_limit_errorretries,not_logged_in/incorrect_loginrequests a refresh (which cannot help because there is no refresh token; reconnect), and bans, missing communities, blocked URLs, deleted posts and locked posts become actionablebad_bodymessages. - No refresh.
refresh_token()returns an empty token; credentials never expire, and posting re-logs in anyway. Reconnect after a password change.
References
- Lemmy API documentation (OpenAPI, v4): https://join-lemmy.org/api/main
- Lemmy API v4 upgrade guide (v3 → v4 endpoint changes and v3 compatibility): https://join-lemmy.org/docs/contributors/09-api-v4.html
- Lemmy 1.0 compatibility note: https://join-lemmy.org/news/2026-05-19_-_Open_beta_testing_for_Lemmy_1.0.0
- Postiz provider settings: https://docs.postiz.com/public-api/providers/lemmy
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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_basicprofileand 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 withnot_enough_scopesor LinkedIn rejects the authorization withinvalid scope. See Implementation Notes & Gotchas.
Overview
| Property | Value |
|---|---|
| Identifier | linkedin |
| Display name | |
| Connection mode | oauth (authorization code, no PKCE) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; replies are posted as comments on the parent post and may only contain text |
| Pending / async publishing | Yes. supports_pending = true; post_pending → check_post_status → finalize_post |
| Analytics | No for the member provider (supports_analytics = false; analytics() returns Unsupported). Page analytics live in linkedin-page |
| Post analytics | No (supports_post_analytics = false) |
| Mentions | Yes, company pages only. supports_mentions = true, mention_format emits @[Name](urn:li:organization:<id>) |
is_between_steps | false |
convert_to_jpeg | false (media is re-encoded inside prepare_media_buffer, not by the engine) |
max_concurrent_job | 8 |
Character limit (max_length) | 3000 characters |
| API | LinkedIn REST Posts API, LinkedIn-Version: 202601 (comments use 202306) |
| OAuth scopes | openid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social |
| Token lifetime | 60-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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
LINKEDIN_CLIENT_ID | Yes | none | LinkedIn app Client ID (Auth tab). |
LINKEDIN_CLIENT_SECRET | Yes | none | LinkedIn app Client Secret (Auth tab). |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the OAuth redirect URI. Trailing slashes are stripped by Config::frontend_url(). |
LINKEDIN_BASE_URL | No | https://api.linkedin.com | Overrides the REST API origin. Trailing slash stripped. |
LINKEDIN_OAUTH_BASE_URL | No | https://www.linkedin.com | Overrides 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.
-
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.
-
Verify the app if the Products tab asks for it (business email verification and page association).
-
Add the products under Products:
- Sign In with LinkedIn using OpenID Connect — grants
openid,profile(andemail, 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_adminandw_organization_social. Postiz’s docs explicitly recommend requesting it because it is also the path to programmatic refresh tokens. r_basicprofileis a legacy Marketing API permission. The self-serve “Sign In with LinkedIn using OpenID Connect” product grantsprofile, notr_basicprofile, so adding only the two self-serve products is not enough.
- Sign In with LinkedIn using OpenID Connect — grants
-
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.
-
Add the redirect URI under Auth → OAuth 2.0 settings → Authorized redirect URLs:
{FRONTEND_URL}/integrations/social/linkedinExample:
https://social.example.com/integrations/social/linkedin. No trailing slash. LinkedIn requires an absolute HTTPS URL;http://localhostis commonly accepted for development but is not what LinkedIn documents. Query parameters are ignored; a fragment (#) is invalid — register the bare path. -
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" -
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:
| Scope | Granted by |
|---|---|
openid, profile | Sign In with LinkedIn using OpenID Connect |
w_member_social | Share on LinkedIn (also Community Management / Advertising API) |
r_basicprofile | Community Management API or Advertising API |
rw_organization_admin | Community Management API or Advertising API |
w_organization_social | Community Management API or Advertising API |
r_organization_social | Community 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/, soFRONTEND_URL=https://x/still yieldshttps://x/integrations/social/linkedin.- There is no
redirectmeto.comrelay (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 andconnectChannelTooldo not passrefresh, so this only matters for a client that calls the reconnect endpoint directly. If the exchange ever fails withinvalid_redirect_uri, this is the first thing to inspect. - The page provider never appends
?refresh(itsauthenticateforces 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_GETis 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 hasADMINISTRATORorCONTENT_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 reportsnot_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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
company | url — a company page URL such as https://www.linkedin.com/company/acme | [name, "@[Name](urn:li:organization:<id>)"] or null | Resolves the vanity name and returns the exact tag value to paste into the post text. |
mention | query — 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()
| Field | Type | Required | Behavior |
|---|---|---|---|
post_as_images_carousel | boolean | No | If 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_name | string | No | Title 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
settingsis not a JSON object, validation returnsOk(()): 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 containingmp4, otherwise:"Carousel can only be created with 2 or more images and no videos." - More than one media item with any
mp4in 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 theuploadInstructions[0].uploadUrl, collecting theetagof every part, thenPOST /rest/videos?action=finalizeUploadwithuploadedPartIdsand an emptyuploadToken. Initialization sendsfileSizeBytes,uploadCaptions: false,uploadThumbnail: false. - PDF carousel:
convert_images_to_pdf_carouselre-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,/DCTDecodeJPEG streams). The resulting PDF is uploaded through thedocumentsendpoint withContent-Type: application/pdf. The first post is rewritten to reference one syntheticcarousel.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
post_pendinguploads all media and returnsPostResponse::pendingwith:authorId,postType(personal),message,isPdf,pdfTitle,mediaIds,poll(urns still processing),graceChecks.check_post_statuspolls each urn:- non-
AVAILABLE→ still processing; PROCESSING_FAILED→BadBodywith the failure reason;- a status-call error is tolerated up to 10 consecutive stalls;
- once everything is available, if
attempting && !confirmedit flipsconfirmed = trueand returnsReadywithout creating the post.
- non-
finalize_post:- if not
attempting/confirmed, setsattempting = true,confirmed = falseand returnsPending(the “arm” step); - on the next pass (
attempting && confirmed) it creates the post.
- if not
- If
attempting && confirmedis observed again incheck_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. 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()→Unsupportedfor every method.autoRepostPostandautoPlugPostare not available on personal accounts.run_post_plug()supports:addComment— postsinformation["comment"]as a comment using the integration’s own token/actor (urn:li:person:<internalId>).repostPostUsers— resharespost_idwithreshareContext.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=noneis sent on the authorization URL. LinkedIn’s documented parameters are onlyresponse_type,client_id,redirect_uri,stateandscope;promptis a port artifact. It is harmless today, but if LinkedIn starts validating unknown parameters it can break the login.- No PKCE.
generate_auth_urlreturns acode_verifierand the state is random, but the authorization URL has nocode_challengeand the token request sendsclient_secret. Treat the flow as classic confidential-client OAuth. - The token response’s
scopeis authoritative. If the app is missing a product, the login can appear to succeed and then fail withnot_enough_scopesafter the code exchange. Check the Auth tab before debugging anything else. - 60-day access tokens; programmatic refresh is partner-only.
refresh_token()postsgrant_type=refresh_tokento/oauth/v2/accessTokenwith the app credentials. If LinkedIn did not issue a refresh token, Rustiz storesNone, the token eventually returns 401, the generic HTTP layer classifies 401 asRefreshToken, the engine tries to refresh with an empty token, fails, marks the channelrefreshNeededand notifies the org. This is exactly why the Postiz guide insists on the Advertising API form. - Token refresh is reactive, not scheduled.
refresh_cronis false. The only proactive refresh in the engine applies to providers withrefresh_cron = true; LinkedIn is refreshed when an API call produces a 401/RefreshToken(publish, analytics, trigger). one_time_token = trueandrefresh_wait = trueare 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 send202306, 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 producehttps://www.linkedin.com/embed/feed/update/<id>. - Text is escaped, not markdown.
fix_textescapes\ < > # ~ _ | [ ] * ( ) { } @outside company mentions. Company mentions written as@[Name](urn:li:organization:<id>)are preserved. This means#hashtagbecomes\#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 = truemeans “company mentions”. r_basicprofiledoes not grant member search. Even with the scope, the provider only readssub,name,pictureandvanityName.- Personal analytics really are unsupported.
analytics()andpost_analytics()returnUnsupportedwhenpage == false. Uselinkedin-pagefor organization metrics. - Signed media URLs can change the format.
mime_lookuptakes 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.pngpaths 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 3-legged OAuth (parameters, redirect rules, token response, refresh behavior): https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow
- LinkedIn API products and permissions (Community Management / Advertising scope tables): https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access
- Programmatic refresh tokens: https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens
- Sign In with LinkedIn using OpenID Connect: https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2
- Images API limits: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
- Videos API: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api
- LinkedIn app registration: https://www.linkedin.com/developers/apps
- Postiz LinkedIn guide: https://docs.postiz.com/self-host/providers/linkedin
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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(wrapscrates/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
| Property | Value |
|---|---|
| Identifier | linkedin-page |
| Display name | LinkedIn Page |
| Connection mode | oauth, then channelSetupTool (page selection) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; comments are authored as the organization |
| Pending / async publishing | Yes. supports_pending = true; same media → arm/confirm → create flow |
| Analytics | Yes. supports_analytics = true; organization page, follower and share statistics |
| Post analytics | Yes. supports_post_analytics = true; per-share statistics with a social-actions fallback |
| Mentions | Yes, company pages only. supports_mentions = true |
is_between_steps | true — a channel is unusable until a page is selected |
convert_to_jpeg | false |
max_concurrent_job | 2 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 |
| API | LinkedIn REST Posts API, LinkedIn-Version: 202601 (comments 202306) |
| OAuth scopes | openid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social |
| Token lifetime | 60-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"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
LINKEDIN_CLIENT_ID | Yes | none | Same app as linkedin. |
LINKEDIN_CLIENT_SECRET | Yes | none | Same app as linkedin. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI. Trailing slashes stripped. |
LINKEDIN_BASE_URL | No | https://api.linkedin.com | REST API origin. |
LINKEDIN_OAUTH_BASE_URL | No | https://www.linkedin.com | Authorize/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.
-
Create the app at https://www.linkedin.com/developers/apps/new and associate the company page you administer.
-
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 requiresrw_organization_admin, and posting requiresw_organization_social.
- Sign In with LinkedIn using OpenID Connect →
-
On the Auth tab, verify the OAuth 2.0 scopes list has all seven scopes from linkedin.md.
-
Add both authorized redirect URLs:
https://social.example.com/integrations/social/linkedin https://social.example.com/integrations/social/linkedin-pageThe 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. -
Set
LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRETandFRONTEND_URL, restart, and confirm:rustiz providers --status | jq '.[] | select(.identifier=="linkedin-page")' -
Connect the channel, then select a page (next section). A freshly connected channel has
inBetweenSteps=trueand 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_URLhas 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 testauthenticate_prefixes_id_and_never_adds_refresh_to_redirectasserts 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
ADMINISTRATORorCONTENT_ADMINISTRATORon 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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
companies | none | array 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:
| Field | Type | Required | Behavior |
|---|---|---|---|
post_as_images_carousel | boolean | No | Combine the first post’s images into a PDF document carousel. Needs ≥ 2 images, no video. |
carousel_name | string | No | PDF 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_payloadusesauthor = urn:li:organization:<internalId>.upload_pictureuses the organization owner for initialize-upload requests.- Company images and documents are polled through
/rest/images/<urn>and/rest/documents/<urn>untilAVAILABLE; personal images/documents only get a one-cycle grace check. - Comments use
actor = urn:li:organization:<internalId>and theLinkedIn-Version: 202306header. - 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):
| Method | Fields | Behavior |
|---|---|---|
autoRepostPost | likesAmount | Reads 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. |
autoPlugPost | likesAmount, post | Same 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>;channelSetupToolreplaces 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()needsrw_organization_admin;page_information()additionally reads the organization’slocalizedName,vanityNameandlogoV2. A member with onlyw_organization_socialcan post but cannot list/select pages. - Role filter is strict. Only
ADMINISTRATORandCONTENT_ADMINISTRATORare returned.ANALYST,RECRUITER,LEAD_GENERATIONand similar roles are invisible even though they may see the page in LinkedIn. - Analytics has a fixed
percentage_change = 5.0.page_analyticsalways 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,organizationalEntityFollowerStatisticsandorganizationalEntityShareStatisticswith 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 onlyLikesandCommentsfor today. Empty labels are filtered out. post_analytics()returns labels lazily. Unlikepage_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_plugworks,run_post_plugdoes not. Personal:run_post_plugworks,run_plugdoes not. This is faithful to upstream, but surprising. autoRepostPostandautoPlugPosttake 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.
ProviderInfosaysmax_concurrent_job = 2, but the engine keys queues on the identifier prefix before-, solinkedinandlinkedin-pageshare thelinkedinqueue and the code takes the maximum (8). Do not size rate-limit budgets from the declared 2. companies()does not paginate. The ACL request setsq=roleAssigneeandstate=APPROVEDbut nocount/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 intriggerTool; pass a page id directly tochannelSetupToolif you already know it.refresh_token()returns the member, not the page. It callsdetails_from_token(..., prefix_id = false), so the returned id is the bare membersub(nolinkedin-page_prefix and no organization id). The engine’s refresh path only stores the access/refresh token and expiry, so the channel’s organizationinternal_idis 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()callspages()andfetch_page_information()with the required organization id, returning the page’s name/logo/vanity name. It is only reached by the engine whenroot_internal_iddiffers frominternal_id; normal OAuth channels leaveroot_internal_idnull, so the shared member token is used directly. - Rate limits and versions are shared with the personal provider. The same
202601/202306headers, the samefix_textescaping and the same company-only mention format apply.
References
- LinkedIn 3-legged OAuth: https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow
- API products and scopes: https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access
- Community Management app review / tiers: https://learn.microsoft.com/en-us/linkedin/marketing/community-management-app-review
- Organizations and ACL lookup: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-access-control
- Share on LinkedIn: https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
- Postiz LinkedIn Page guide: https://docs.postiz.com/self-host/providers/linkedin-page
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | listmonk |
| Display name | ListMonk |
| Connection mode | customFields (instance URL, API user, API token) |
| Editor | html |
| Channel kind | social (email campaign) |
| Comments / threads | No. supports_comments = false; only the first PostDetails is used |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Completed immediately after starting the campaign |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 100 |
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 images | Yes (inline_images = true) |
| API | Listmonk 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
JWT_SECRET | No, 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_URL | No | the channel’s stored URL | Global 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_NETWORK | No | false | Global 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.
-
Sign in to Listmonk as an admin.
-
Create a user role with the permissions Rustiz needs. In Listmonk v4+ go to Admin → Users → User roles → New:
settings:get—authenticate()callsGET /api/settings.lists:get_all— thelisttool callsGET /api/lists. Alternatively, attach a List role with at least View access to the target list.templates:get— thetemplatestool callsGET /api/templates.campaigns:manage(orcampaigns:manage_all) — create the campaign.campaigns:send— start the campaign. This is a separate permission and is required even if the role hascampaigns:manage_all.
-
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. -
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.
-
Confirm the instance is reachable from the Rustiz server. HTTPS is strongly recommended; Basic auth sends the token on every request.
-
Verify the credentials:
curl -s -u 'rustiz_api:TOKEN' \ https://listmonk.example.com/api/settingsA 200 with a
data.app.site_namefield 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/listsandGET /api/templatesare paginated by Listmonk. Rustiz does not passpage/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 nosend_atscheduling 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=trueon 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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
url | text | `/^(https?://)(?:\S+(?::\S*)?@)?(?:(?:localhost) | (?:\d{1,3}(?:.\d{1,3}){3}) |
username | text | /^.+$/ | Listmonk API user name, not the admin login. |
password | password | /^.{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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
list | none | [{"id": <number>, "name": "..."}] | Calls GET /api/lists, reads data.results. Only the default first page. Requires lists:get_all or a list role. |
templates | none | [{"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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
subject | string | Yes | minLength: 1; must be a non-empty string. Used as the campaign subject and, slugified, as the campaign name. |
preview | string | Yes | Must be a string; it may be empty. Injected into the hidden preheader block of the HTML email. |
list | string | Yes | List id as a string. Parsed to an integer at publish time; a non-numeric string becomes null. |
template | string | No | Template 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
subjectmust be a non-empty string.previewmust be a string (empty allowed).listmust be a string. There is no numeric check, so"abc"passes validation and becomes anulllist at publish time.template, if present and notnull, must be a string. A non-numeric string passes validation and is silently ignored at publish time.
Publish behavior
POST /api/campaignswith:name:slugify(subject)— Rustiz’s own slugifier maps&toand,|toor,<toless,>togreater, lowercases alphanumerics, and keeps Unicode letters/digits (unlike npmslugifystrict 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.
- Reads
data.uuidas the post id anddata.idas the campaign id. PUT /api/campaigns/{id}/statuswith{"status":"running"}.- Returns
post_id = uuidandrelease_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 = truemeans Rustiz’s HTML sanitizer keeps<img>tags in the content and allows theimgtag, so images must be public URLs in the HTML body. - The post
media/attachmentsarray is ignored. check_validityis 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 sendsend_atand does not leave the campaign as a draft. It creates the campaign and then sets status torunning. 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_URLis 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()setsaccess_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. listandtemplateids are strings in settings but integers on the wire. A non-numericlistpasses validation and produceslists: [null]. Listmonk will normally reject that campaign; validate the id before scheduling.- Tools show only the first page. Neither
list()nortemplates()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
releaseURLis 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; thesubjectvalidator prevents an empty subject, but a subject made only of punctuation can still produce an empty name. Listmonk may reject it. inline_imagesonly 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()orpost_analytics(); the flags arefalse. - 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
- Listmonk API introduction (Basic auth with API users, response envelope, error codes): https://listmonk.app/docs/apis/apis/
- Listmonk campaigns API (create, status transitions, preview): https://listmonk.app/docs/apis/campaigns/
- Listmonk user roles and permissions (including
campaigns:send): https://listmonk.app/docs/roles-and-permissions - Postiz provider settings: https://docs.postiz.com/public-api/providers/listmonk
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | mastodon-custom |
| Display name | M. Instance |
| Connection mode | externalUrl (the server registers an OAuth app on the instance) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true |
| Pending / async publishing | Yes. supports_pending = true |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false (the instance URL is collected before the OAuth login, not as a second step) |
convert_to_jpeg | false |
max_concurrent_job | 5 |
Character limit (max_length) | 500 characters, hard-coded |
| OAuth scopes | write: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"]
}
}
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
MASTODON_URL | No | https://mastodon.social | Fallback 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_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI registered on every instance |
JWT_SECRET | Yes | empty | Encrypts 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:
| Field | Value |
|---|---|
client_name | Rustiz (hard-coded) |
redirect_uris | {FRONTEND_URL}/integrations/social/mastodon |
scopes | write: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, notmastodon-custom. The OAuth app registered on the user’s instance must use/integrations/social/mastodon. - There is no trailing slash and no
redirectmeto.comwrapper. - The callback route is
GET /integrations/social/{provider}and ignores the path provider when resolving state; it looks up the state row, which storesprovider = "mastodon-custom". This is why the shared path works. - If
FRONTEND_URLishttp://localhost:5000, the registered redirect ishttp://localhost:5000/integrations/social/mastodon. Some instances refuse plain-http redirects except for loopback; most acceptlocalhost.
Scopes requested and checked
The app is registered with and the authorize URL requests:
write:statuses profile write:media
| Scope | Why |
|---|---|
write:statuses | Create the status/replies |
profile | verify_credentials during connect |
write:media | Upload 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-urlis 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/appsand before storing it.https://fosstodon.org/andhttps://fosstodon.orgare equivalent. - With a local
FRONTEND_URL, the CLI serves the callback and waits for the channel. With a public HTTPSFRONTEND_URL, it prints the login URL and exits; pollrustiz 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.rsgivescustomFieldsprecedence over every other flow. Formastodon-custom,connect_custom()would callauthenticate()without aClientInformation; the custom branch requires one and returnsMissing instance details. The error is confusing because the flow never reachesexternal_url(). externalUrlmust 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/appsresponse 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
descriptionmultipart field, only when non-empty. - Media is uploaded to
{instance}/api/v1/media(v1), then polled throughGET /api/v1/media/:iduntil200. - 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
Rustizand 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"andwebsite=FRONTEND_URL. To white-label, change the Rust code; there is no env var. instance_url()silently falls back. IfJWT_SECRETchanges, the encryptedcustomInstanceDetailscannot be decrypted;instance_url()returnsMASTODON_URL(defaulthttps://mastodon.social) and the channel’s token from the original instance is sent there. Expect a401, not a clear “wrong instance” error. Back upJWT_SECRET.- Trailing slash handling.
external_url()trims the URL;instance_url()trims it again;auth_url()trims it a third time.https://host/works, buthttps://host/api/v1does not. - No environment readiness check.
provider_status()sees no required env vars, so the UI/agent can start a connection even whenFRONTEND_URLis 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 andconnect.rsoauth_url_configured()rejects an emptyclient_id(orundefined) in the query string, deletes the pending state and returns “its OAuth client id is empty”. If an instance acceptsPOST /api/v1/appsbut 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-Keyon finalize, public-only visibility, missing comment idempotency, and hard-coded 500-character limit all apply. See themastodonpage for the full list. - Rate limits are per instance.
max_concurrent_job = 5is 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. externalUrlis only used at connect time. Publishing uses the storedinstanceUrl, 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 app registration: https://docs.joinmastodon.org/methods/apps/
- Mastodon OAuth: https://docs.joinmastodon.org/spec/oauth/
- Mastodon media: https://docs.joinmastodon.org/methods/media/
- Postiz Mastodon setup (single-instance example): https://docs.postiz.com/self-host/providers/mastodon
- Rustiz provider table: Project README
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
| Property | Value |
|---|---|
| Identifier | mastodon |
| Display name | Mastodon |
| Connection mode | oauth (a human opens a login URL) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are published as replies with in_reply_to_id |
| Pending / async publishing | Yes. supports_pending = true; media is uploaded first, then polled, then the status is created |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 10 (Mastodon limits are per user, not per app) |
Character limit (max_length) | 500 characters, hard-coded |
| OAuth scopes | write: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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
MASTODON_CLIENT_ID | Yes | — | Client id returned by POST /api/v1/apps on MASTODON_URL |
MASTODON_CLIENT_SECRET | Yes | — | Client secret returned by the same call |
MASTODON_URL | No | https://mastodon.social | Instance that hosts the app and the accounts. Trailing slashes are stripped. Read with Config::get_or, not base_url. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI |
JWT_SECRET | Yes in practice | empty | Encrypts 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()andcomment()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"]
}
| Scope | Why Rustiz needs it |
|---|---|
write:statuses | POST /api/v1/statuses (posts and replies) |
profile | GET /api/v1/accounts/verify_credentials during connect |
write:media | POST /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.socialallows 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;
--openlaunches 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--timeoutseconds (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. Pollrustiz channels connect-status <id>orrustiz 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
externalUrlis not supported bymastodon:begin()callsMastodon::external_url(), which returnsUnsupported("externalUrl"). Usemastodon-custom.customFieldstakes precedence for any provider inconnect.rs. Sending an object tomastodonroutes toconnect_custom(), which encodes it as thecodeofauthenticate(); that expects an OAuth authorization code, so the connection fails. Never sendcustomFieldstomastodon.
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:
| Media | Mastodon limit | Notes |
|---|---|---|
| Images | Up to 4 per status, 16 MB each (PNG, JPG, HEIF, WEBP, AVIF) | Downscaled by the instance to about 8.3 MP |
| Animated GIF / GIFV | 1 per status, 16 MB | Converted to a soundless MP4; < 1 MP |
| Video | 1 per status, 99 MB (MP4, M4V, MOV, WebM) | Transcoded to H.264 MP4 |
| Audio | 1 per status, 99 MB | Transcoded to MP3 |
| Alt text | Per item | Sent 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:
- The first entry as a status.
- Each later entry as a reply, with
in_reply_to_idset to the previous status id (or the root post id for the first reply). - 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}inpending_data, and returnsPostStatus::Pending. The engine then pollscheck_post_status()every 20 seconds (up to 90 checks, about 30 minutes). When every media id answers200, it callsfinalize_post()to create the status. - Media polling semantics.
GET /api/v1/media/:idreturns206 Partial Contentwhile processing and200 OKwhen ready.401becomes a refresh error.404/410are treated as “already attached by an earlier finalize” and skipped, not as a hard failure.422means the upload expired and is reported asThe 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 inpost_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 anIdempotency-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 usespost_pending()becausesupports_pending = true. - Always public. Both
finalize_post()andcomment()hard-codevisibility=public. There is no setting forunlisted,private,direct, content warnings, sensitive media, language or polls. - Hard-coded 500 characters.
max_length()returns 500 regardless of the instance’sconfiguration.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, thenPOST /statusesfails. Check the account’s instance limits before scheduling large carousels. - The v1 media endpoint is deprecated. The code uses
POST /api/v1/mediaand accepts200,201or202. Mastodon 3.1.3+ documentsPOST /api/v2/mediaas the replacement; v1 still works and blocks until the attachment is processed. The follow-upGET /api/v1/media/:idpoll remains correct for both. - Media MIME type. The multipart
filepart is built withPart::stream(...).file_name(...)and noContent-Type. Mastodon’s docs ask for a part with a MIME type. Reqwest may sendapplication/octet-stream; if an instance rejects an otherwise valid image withFile content type is invalid, set the part’s MIME type by content sniffing. - No token refresh. Mastodon access tokens normally do not expire.
authenticate()storesrefresh_token: Some("null")(the literal string) andexpires_inof 100 years plus 25 days.refresh_token()returns an empty access token andexpires_in: 0. A401therefore cannot be repaired automatically; reconnect the channel. A401during media polling is explicitly converted to a refresh error. MASTODON_URLis read at publish time. For the standard provider,instance_url()always returns the currentMASTODON_URL, not the instance used at connect time. ChangingMASTODON_URLafter connecting makes every existing channel post its stored token to the new host.MASTODON_URLdefault.https://mastodon.social. If you leave it unset and register the app on another instance, the login will fail with aninvalid_clientpage 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 retries429/5xxup 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 = 10is 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 canonicalurl(usuallyhttps://{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
- Mastodon OAuth: https://docs.joinmastodon.org/spec/oauth/
- Registering an app (
POST /api/v1/apps): https://docs.joinmastodon.org/methods/apps/ - Posting a status: https://docs.joinmastodon.org/methods/statuses/
- Media upload and the 206 processing status: https://docs.joinmastodon.org/methods/media/
- Rate limits: https://docs.joinmastodon.org/api/rate-limits/
- Postiz Mastodon setup: https://docs.postiz.com/self-host/providers/mastodon
- Postiz Mastodon settings: https://docs.postiz.com/public-api/providers/mastodon
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
mediumprovider cannot be used. See https://help.medium.com/hc/en-us/articles/213480228-API-Importing.
Overview
| Property | Value |
|---|---|
| Identifier | medium |
| Display name | Medium |
| Connection mode | customFields (legacy integration token) |
| Editor | markdown |
| Channel kind | social (article content) |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Completed |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 100000 characters for the story body |
| API | Medium 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
MEDIUM_BASE_URL | No | https://api.medium.com | Overrides 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.
-
Sign in to Medium.
-
Open Settings → Security and apps → Integration tokens (https://medium.com/me/settings). Medium’s own API documentation calls the section Integration Tokens.
-
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. -
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,publishPostand optionallyuploadImage, 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"wheneversettings.publicationis set. A draft in a publication stays pending until an editor publishes it. - A post without
publicationusespublishStatus: "public"and is live immediately. GET /v1/meandGET /v1/users/{id}/publicationsare 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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
apiKey | password | /^.{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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
publications | none | The data array from GET /v1/users/{id}/publications | Uses 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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | Yes | minLength: 2; checked. Medium itself ignores titles longer than 100 characters and synthesizes one from the content. |
subtitle | string | Yes | minLength: 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. |
canonical | string | No | Sent as canonicalUrl. If it contains (post:, URL validation is skipped. |
publication | string | No | Medium 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". |
tags | array | No | maxItems: 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
titlemust exist, be a string and have at least 2 characters.subtitlemust exist, be a string and have at least 2 characters.canonical, if present and a string, must be a valid URL unless it contains(post:.publication, if present and notnull, must be a string. An empty string is accepted and means “publish on the profile”.tags, if present and notnull, must be an array of at most 4 items. Each item must be an object with a stringvalueand a stringlabel. 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_imagefield in the schema. Medium is not sent a cover image and Rustiz does not call Medium’s image upload endpoint. - The post
media/attachmentsarray 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()setspublishStatus: "draft"wheneverpublicationis 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 nounlistedsetting 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.
labelis not sent;valueis. - 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, butpost()sends any non-emptycanonicalstring ascanonicalUrl. Omit the field to leave the canonical URL out.- No refresh. Tokens are stored with a 100-year
expires_inand an empty refresh token;refresh_token()returns an empty access token. A revoked token requires reconnecting with another legacy token. publicationsneeds the Medium user id. It usesintegration.internal_id, whichauthenticatesets fromdata.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()returnsNone; 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
- Medium API/importing status (no new integration tokens): https://help.medium.com/hc/en-us/articles/213480228-API-Importing
- Medium API documentation (archived, self-issued tokens, publication rules, no sandbox): https://github.com/Medium/medium-api-docs
- Medium account settings: https://medium.com/me/settings
- Postiz provider settings: https://docs.postiz.com/public-api/providers/medium
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | mewe |
| Display name | MeWe |
| Connection mode | oauth |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments is the default false; comment() is not implemented |
| Pending / async publishing | No. supports_pending = false; post() returns PostStatus::Success |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 1 (default) |
Character limit (max_length) | 63_206 |
| API host | https://mewe.com (MEWE_BASE_URL, or MEWE_HOST) |
| API style | REST 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
MEWE_APP_ID | Yes | "" | The app id (X-App-Id) from the MeWe developer portal. |
MEWE_API_KEY | Yes | "" | The app’s secret API key (X-Api-Key). |
MEWE_BASE_URL | No | — | Preferred host override; base_url("MEWE", ...) reads MEWE_BASE_URL first and strips trailing slashes. |
MEWE_HOST | No | https://mewe.com | Fallback host used only when MEWE_BASE_URL is unset. This is a legacy/default name, not the *_BASE_URL override. |
FRONTEND_URL | No | http://localhost:5000 | Public origin; the OAuth redirect URI is derived from it. Trailing slashes are stripped. |
JWT_SECRET | No | "" | 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:
| Capability | Endpoint used | Permission family |
|---|---|---|
| Read the signed-in profile | GET /api/dev/me | profile / basic user info |
| List groups | GET /api/dev/groups | groups read (MeWe’s group-agent docs call this user_groups) |
| Post to the timeline | POST /api/dev/me/post | post/create content (post_content) |
| Post to a group | POST /api/dev/group/{groupId}/post | groups + post/create content |
| Upload a photo | POST /api/dev/photo/upload | photo/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
-
Browser goes to
{MEWE_HOST}/login?client_id=...&redirect_uri=...&state=.... -
The user signs in and approves the app.
-
MeWe redirects to the callback with
loginRequestTokenandstate:https://social.example.com/integrations/social/mewe?loginRequestToken=abc123&state=xyz789 -
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: trueresponse 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.” -
Rustiz fetches
GET /api/dev/mewithAuthorization: Bearer <apiToken>plus the app headers, and storesuserId,name/firstName+lastName, andhandle.
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:
-
Rewrite the callback URL before it is consumed. After MeWe redirects, change the query key
loginRequestTokentocodeand reload:https://social.example.com/integrations/social/mewe?code=abc123&state=xyz789The state row still exists, and
authenticate()receives the login request token asparams.code. This is the simplest way to finish the connection with the current binary. -
Add a tiny proxy/frontend route at
/integrations/social/mewethat forwardsloginRequestTokenascodeto the Rustiz callback. -
Patch the server callback to accept
loginRequestTokenas an alias forcode. 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:
--fieldsis thecustomFieldspath; it base64-encodes JSON and passes it toauthenticate()as the OAuth code. MeWe expects aloginRequestToken, so the connection fails.--external-urlcallsexternal_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:
| Function | Parameters | Returns |
|---|---|---|
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()
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
postType | string enum | Yes | "timeline" or "group". |
group | string | Conditional | Required 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
postTypemust be exactly"timeline"or"group".- If
postType == "group",groupmust be a non-empty string. - 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:
-
postTypedecides the endpoint:- timeline →
POST {host}/api/dev/me/post - group →
POST {host}/api/dev/group/{group}/post
- timeline →
-
Body:
{ "text": "<message>", "uploadedPhotoIds": ["<photo id>", "..."] }uploadedPhotoIdsis 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), checksis_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 aspost_id. This is not a real MeWe id. -
release_url:- timeline →
https://mewe.com/{integration.profile}/posts - group →
https://mewe.com/group/{group}
integration.profileis the storedhandle; if it is empty the timeline URL becomeshttps://mewe.com//posts. - timeline →
-
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_extensionlowercases the path and checks for the substring.mp4. Therefore:.mp4,.MP4, and even.mp4?x=1are 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/uploadas multipart fieldfile. The responseidis collected; a response withoutidcontributes 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 expectscode. Without a rewrite or proxy, the connection cannot complete. See the workaround above. - No refresh token.
refresh_token()returns an all-emptyAuthTokenDetailswithexpires_in: 0. The engine treats an empty access token as a failed refresh, setsrefresh_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. IfexpiresAtis missing or unparsable, the provider assumes 30 days. A past timestamp yields a negativeexpires_in. - A 401 on publish does not trigger a refresh.
post()andupload_photo()use the rawexecute()client and convert any handled error intoProviderError::Other, losing therefreshclassification. Expired tokens therefore surface as retryable failures; reconnect the channel. - Publish bypasses the retry client. The 204 response is why
post()does not usesend_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_urlis the only link back. - Timeline
release_urldepends on the handle. It useshttps://mewe.com/{handle}/posts; a profile without a handle produces a double slash. groupsswallows errors. A failed page stops pagination silently. The result can be partial.nextPageis prefixed with the host unconditionally. The code doesformat!("{host}{p}"); if MeWe ever returns an absolute URL innextPage, the result becomeshttps://mewe.comhttps://...and the next request fails.groupsignores its parameters. The function schema has no properties; the MCPdataSchemais ignored. There is no search or page-size control.- Only
.mp4is 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-Keyis required only for authorization requests;auth_headers()sends it onGET /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 Calmor420→ retry,Forbidden→ bad body. The strings are case-sensitive and are not MeWe error codes. Becausepost()converts every handled error toProviderError::Other, aForbiddenon 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
420as retryable, butpost()/upload_photo()bypass the retrying client, so a 429 during publishing is not retried automatically. pictureis always empty.AuthTokenDetails.pictureisSome(""), so the channel has no avatar in Rustiz.- Name fallback.
nameis used when non-empty; otherwise the provider joinsfirstName+lastNameand trims the result. Both empty produces an empty channel name. stateis 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-charactercode_verifier, but MeWe’s login URL has nocode_challenge/code_challenge_methodand 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
- MeWe developer portal: https://developer.mewe.com/
- MeWe quickstart / developer program: https://developer.mewe.com/quickstart
- MeWe authentication (standalone OAuth): https://developer.mewe.com/authentication
- MeWe API communication / headers: https://developer.mewe.com/api-communication
- MeWe API reference (groups, group post, timeline post, photo upload): https://developer.mewe.com/api-docs
- Postiz MeWe settings: https://docs.postiz.com/public-api/providers/mewe
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
| Property | Value |
|---|---|
| Identifier | moltbook |
| Display name | Moltbook |
| Connection mode | moltbookClaim (server registers, human claims) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Advertised 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 publishing | No. supports_pending = false; post() returns Completed per entry |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 100 (the comment says “100 requests/minute”; current write limits are lower) |
Character limit (max_length) | 300 characters, hard-coded |
| Web3 flag | is_web3 = true |
| Scopes | None |
| Redirect URI | None |
Server Environment Variables
required_env() returns nothing:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&[]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
MOLTBOOK_BASE_URL | No | https://www.moltbook.com/api/v1 | API base. Must include /api/v1. Trailing slash trimmed. |
MOLTBOOK_PUBLIC_BASE_URL | No | https://www.moltbook.com | Base used only to build release URLs (/post/<id>). Not the API base. |
JWT_SECRET | Yes | empty | Encrypts 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_keyis shown once. Rustiz encrypts the pending claim’s{apiKey, claimUrl, verificationCode}withJWT_SECRETwhile 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_urlis for the human owner.verification_codeis the code the human includes in the claim tweet.- The name is the agent’s identity. Rustiz requires a non-empty
namefor a new claim (descriptiondefaults toRustiz social media scheduler).
2. Human claims the agent
The owner:
- Opens the
claim_url. - Verifies an email address (this creates the owner dashboard login).
- Posts a verification tweet from X containing the
verification_code. - 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
submoltis present and notnull, 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_nameas preferred andsubmoltas an accepted alias; Rustiz sendssubmolt. - The API documents title max 300 chars and content max 40,000 chars.
Rustiz’s
max_lengthis 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
verificationobject with an AI/math challenge before a post becomes visible. Rustiz does not handle this challenge: it only checkssuccessand readspost.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_idis 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()requiressuccess: true. The code checksresponse["success"].as_bool().unwrap_or(false)and returnsRegistration failedotherwise, then readsresponse["agent"]. The official Moltbook skill’s registration sample shows only{ "agent": {...}, "important": "..." }, with no top-levelsuccess. 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 underdata. Verify the raw response with the curl command above before relying on the claim flow.get_agent_profile()also requiressuccess: true./agents/mesample responses do includesuccess: trueandagent, so this is usually fine.check_agent_status()does not checksuccess; it only readsstatus, so the claim poll works with the documented{ "status": "pending_claim" }shape.post()andcomment()use the samesuccess+ nested-object pattern; a different write envelope fails those calls even if the object was created.- The API key is the identity.
authenticate()setsidto the agent’s name when the profile has one, not the numeric agent id. The name becomesintegration.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 toprofile.id. - The claim TTL is one hour and is not refreshed.
poll_moltbook()reads the state but never callsput_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)andstatus["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 = 100is 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 = 300is 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 Rustmax_lengthof 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 = falsedespite a workingcomment(). 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, setinfo.supports_comments = true; the provider code already supportsparent_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 withcustomFields.apiKeyafter the agent is claimed. - No profile pictures.
authenticate()setspicture: Some(""); the channel list shows no avatar even if Moltbook has one. - The name is required for registration.
start_moltbook()rejects a missing/blanknamebefore calling the API:Moltbook needs an agent name to register.descriptionis optional and defaults toRustiz social media scheduler. customFieldstakes precedence. If an object is passed toconnectChannelTool,connect.rsroutes toconnect_moltbook_key()and ignoresname/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
successcheck, 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.rsspecial-casesmoltbookbefore 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
- Moltbook: https://www.moltbook.com
- Official agent skill / API instructions: https://www.moltbook.com/skill.md
- Moltbook API README (registration, posts, comments): https://github.com/moltbook/api
- Postiz Moltbook settings: https://docs.postiz.com/public-api/providers/moltbook
- Rustiz connect flow source:
crates/rustiz-server/src/connect.rs
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
| Property | Value |
|---|---|
| Identifier | nostr |
| Display name | Nostr |
| Connection mode | customFields (hex private key) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Advertised 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 publishing | No. supports_pending = false; post() broadcasts and returns Completed |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 5 |
Character limit (max_length) | 100000 characters, hard-coded |
| Scopes | None |
| Redirect URI | None |
| Relay default | 5 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
JWT_SECRET | Yes in practice | empty | Signs the HS256 “access token” JWT that wraps the Nostr private key. If it changes, every Nostr channel must reconnect. |
NOSTR_RELAYS | No | 5 built-in relays | Comma-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_PROTECTION | No | unset | Not 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
- Open a Nostr client that can export the key, for example https://iris.to or any NIP-19-aware client.
- Export the hex private key. It must be exactly 64 hexadecimal characters
(32 bytes), with no
0x, no spaces and no newline. - 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), setNOSTR_RELAYSto it, and use a throwaway key. Remember thatNOSTR_RELAYSis 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
| Key | Type | Default | Validation regex (exact string) | Notes |
|---|---|---|---|---|
password | password | — | /^.{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
pathmust 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
imetatags. - 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_toislast_comment_idwhen present, otherwise the rootpost_id.- The
ptag is the channel’s own pubkey (idisintegration.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_commentsisfalse, so the engine never callscomment()for a multi-entry post in practice. A manual provider-level call would work, but MCPvalidatePostToolrejects 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 withu8::from_str_radix(…, 16). It requires exactly 64 hex characters.nsec1…bech32,0x…, uppercaseNSEC, whitespace and newlines all fail. NIP-19 says clients should accept both hex andnsec; this port does not decode bech32. - Validation lets bad keys through.
custom_fields().validationis/^.{3,}$/, so the connect form accepts annsecor a 10-character string. The failure surfaces only after submission asInvalid credentials, becauseauthenticate()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’saccess_token. Anyone with database read access or theJWT_SECRETcan recover the Nostr private key. Treat the Rustiz database andJWT_SECRETas equivalent to the key itself. - The JWT has no expiry.
two_hundred_years()sets the metadataexpires_in, but the JWT payload has onlypasswordandiat; there is noexpclaim, andjwt_verify()does not check one. The token is valid untilJWT_SECRETchanges or the channel is deleted. - Changing
JWT_SECRETbreaks every channel.jwt_verify()uses the current secret; old tokens fail withInvalid 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 withexpires_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 returnsPostStatus::Completed, even when every relay failed. - Empty release URL. If no relay echoes the event,
post_idis empty andrelease_urlishttps://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()andquery_first()iterate the relay list one at a time. Each relay costs up to 10 s to connect plus up to 6 s waiting forEVENT/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_RELAYSis global. There is no per-channel relay list. Two channels cannot publish to different relays from one deployment.- No relay authentication. NIP-42
AUTHis 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, parsescontentas JSON, and acceptsname,displayNameordisplay_name. If no relay answers, the channel name isNo Name, usernamenousername, picture empty. That is a valid connection; the profile can be fixed later by editing kind-0 metadata outside Rustiz and reconnecting. - Comment
ptag 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
rootmarker. Some clients reconstruct the thread from theechain; others show the reply as a top-level note. max_length = 100000is 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 aNOTICEthat Rustiz ignores. Media URLs make long notes easy to hit that cap.- No per-relay error reporting.
publish_to_relay()returnsErr(())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 = 5is a concurrency cap, not a relay-aware rate limiter. Public relays commonly rate-limit or ban keys that publish in bursts. is_web3is false. Unlike Farcaster/Moltbook, Nostr is not marked web3, soconnect.rsdoes 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 usenjump.me,habla.news, etc. The event id is the canonical identifier.
References
- NIP-01 (events, filters, relay messages): https://nips.nostr.com/1
- NIP-10 (reply/thread tags): https://nips.nostr.com/10
- NIP-19 (bech32
nsec/npub): https://nips.nostr.com/19 - BIP-340 (Schnorr signatures): https://bips.dev/340/
- Postiz Nostr settings: https://docs.postiz.com/public-api/providers/nostr
- Iris (key export): https://iris.to
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
| Property | Value |
|---|---|
| Identifier | pinterest |
| Display name | |
| Connection mode | oauth (Authorization Code grant) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published and Pinterest has no pin-comment API |
| Pending / async publishing | Yes. supports_pending = true; video uploads are polled, then the pin is created behind an arm → confirm → publish handshake |
| Analytics | Yes. supports_analytics = true; account analytics |
| Post analytics | Yes. supports_post_analytics = true; pin analytics |
| Mentions | No. supports_mentions = false |
is_between_steps | false — no setup step; the board is chosen in each post’s settings |
convert_to_jpeg | false |
max_concurrent_job | 10 (“Pinterest limits are per user”) |
Character limit (max_length) | 500 characters (the pin description; Pinterest itself allows 800) |
| API base | https://api.pinterest.com (PINTEREST_BASE_URL) |
| Web base | https://www.pinterest.com (PINTEREST_WEB_BASE_URL) |
| Token refresh | Reactive 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
PINTEREST_CLIENT_ID | Yes | — | App ID from My apps. Doubles as the Basic-auth username for /v5/oauth/token. |
PINTEREST_CLIENT_SECRET | Yes | — | App secret key. Doubles as the Basic-auth password. |
PINTEREST_BASE_URL | No | https://api.pinterest.com | API origin. Set to https://api-sandbox.pinterest.com only for sandbox testing. |
PINTEREST_WEB_BASE_URL | No | https://www.pinterest.com | Origin of /oauth/ and of https://www.pinterest.com/pin/{id} release URLs. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global 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
- Log in to Pinterest with a business account (or convert the account). Verify the account email.
- Go to https://developers.pinterest.com/apps/ and accept the Developer Terms of Service.
- Select Connect app, fill in the app details (name, description, use case, privacy policy URL) and submit the request for Trial access.
- Requests are reviewed by Pinterest (usually each business day). You receive an email when approved or denied.
- 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.
| Scope | Why Rustiz needs it |
|---|---|
boards:read | List boards for the board picker |
boards:write | Required by POST /v5/pins even for an existing board |
pins:read | Read media upload status and pin analytics |
pins:write | Create pins and register video media |
user_accounts:read | Read /v5/user_account after connect and before create |
4. Token flow performed by Rustiz
- Authorize at
https://www.pinterest.com/oauth/withresponse_type=code. POST https://api.pinterest.com/v5/oauth/tokenwithAuthorization: Basic base64(client_id:client_secret)andContent-Type: application/x-www-form-urlencoded:grant_type=authorization_code,code,redirect_uri.- Read
access_token,refresh_token,expires_in,scope. GET /v5/user_accountwithAuthorization: Bearer <token>; storeid,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
| Capability | Trial access | Standard access |
|---|---|---|
| Default for a new app | Yes, after review | Requires an upgrade request |
| Pin analytics | Yes | Yes |
| Reading boards/pins | Yes | Yes |
| Creating boards/pins | Yes, visible only to the creator (“Sandbox entities”) | Yes, public according to the board |
| Rate limits | 1,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 use | Not recommended | Recommended |
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:
- The app must already be approved for Trial access and comply with the Developer Guidelines.
- 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.
- My apps → Upgrade, verify the app details and privacy policy, upload the video, submit.
- 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
customFieldsroutes toconnect_custom()and is treated as an OAuth code. Never send it topinterest.externalUrlis 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
}
| Field | Required | Behaviour |
|---|---|---|
board | Yes | Numeric board id. The provider rejects a board name with a helpful message. |
title | No | Pin title, max 100 characters. Sent only when non-empty. |
link | No | Destination URL. Sent only when non-empty. Must be http(s). |
dominant_color | No | Hex 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()
boardmust 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”.title≤ 100 characters (Unicode scalar count).link, when non-empty, must parse ashttp/https.dominant_color, when non-null, must be a string.- No check on the message length here;
max_lengthis 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:
- At least one media: “Requires at least one media”.
- At most 5 media items: “You can only have up to 5 media items”.
- If a video (
.mp4in 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”.
- 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 avideo_idsource withoutcover_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
| Media | How Rustiz sends it | Notes |
|---|---|---|
| Single image | media_source: {source_type: "image_url", url} | Public URL; Pinterest cURLs it |
| Multi-image | media_source: {source_type: "multiple_image_urls", items:[{url}, …]} | 2–5 items, equal dimensions (local check) |
| Video | POST /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
filepart has no explicit MIME type and no filename; S3 usually accepts it. If Pinterest rejects the upload, add a filename andvideo/mp4content type. - The cover image is sent as
cover_image_urland must be publicly reachable. Rustiz does not supportcover_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_stepsisfalse; there is no board setup step.settings.boardis required on every post and is copied intoPendingData.settings.boardbefore the async flow. - Board list is only the first page.
boards()callsGET /v5/boards?page_size=250and never follows thebookmark. An account with more than 250 boards will not see the rest intriggerTool. Add pagination if that matters. - No board sections.
board_section_idis 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 unlessattempting=trueandconfirmed=true. The firstfinalizearms; the nextcheck_post_statuswitnesses; only the followingfinalizesendsPOST /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 callsfetch_user(access_token)before settingattempting=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 storesmedia_id;check_post_status()pollsGET /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; aRefreshTokenerror is propagated.
- Image-only pins skip processing.
media_idis empty, socheck_post_status()goes straight towitness(). There is still an arm → confirm cycle; the first check returns Ready withconfirmed=true, and the next finalize creates the pin. The blockingpost()path sleeps 20 s between checks. refresh_tokenrotation bug. Pinterest returns a new continuous refresh token on every refresh, butrefresh_token()storesSome(refresh_token.to_string())— the old token — instead oftoken["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 storetoken["refresh_token"]when present.- Refresh sends
redirect_uri. Pinterest’s refresh grant does not requireredirect_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_cronisfalse; the hourly sweep will not refresh Pinterest before expiry. Refresh is triggered reactively by a401from 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_inis stored as-is. Pinterest returns2592000(30 days). The engine uses this fortokenExpiration; it does not add a safety margin.- Analytics clamp to 90 days.
analytics()clampsdaysto0..=89and sendsstart_date/end_dateasYYYY-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 whenPIN_CLICK_RATEis present in that day’s metrics, so sparse accounts get gaps. - Post analytics ignore
from_date.post_analytics()always requests the last 89 days withmetric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVEand readsall.summary_metrics. It emits one point per metric dated today; percentage change is0.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. Themessageis sent verbatim asdescription; no HTML stripping is applied (the editor is Normal). - No HTML.
Editor::Normalmeans 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_validityeven 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 bare401triggers 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_write300/day. Standard: 100 requests/second per user per app,org_write100/min,org_read1,000/min. Responses includex-ratelimit-*headers; the provider does not read them or back off.max_concurrent_job = 10is 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
- Pinterest developer apps: https://developers.pinterest.com/apps/
- Connect app / trial access: https://developers.pinterest.com/docs/getting-started/connect-app/
- Access tiers (Trial vs Standard): https://developers.pinterest.com/docs/key-concepts/access-tiers/
- Authentication and scopes: https://developers.pinterest.com/docs/getting-started/set-up-authentication-and-authorization/
- Sandbox: https://developers.pinterest.com/docs/developer-tools/sandbox/
- Create pin: https://developers.pinterest.com/docs/api/v5/pins-create/
- Create boards and pins (video flow): https://developers.pinterest.com/docs/work-with-organic-content-and-users/create-boards-and-pins/
- Rate limits: https://developers.pinterest.com/docs/reference/rate-limits/
- Pin specs: https://help.pinterest.com/en/article/review-pin-specs
- Postiz Pinterest setup: https://docs.postiz.com/providers/pinterest
- Postiz Pinterest settings: https://docs.postiz.com/public-api/providers/pinterest
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
| Property | Value |
|---|---|
| Identifier | reddit |
| Display name | |
| Connection mode | oauth (a human opens a Reddit consent page) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are flat top-level comments on the submission, not nested reply chains |
| Pending / async publishing | Yes. supports_pending = true; post_pending records the subreddit list, finalize_post publishes exactly one subreddit per call |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 1 — Reddit’s write limits are strict and shared per OAuth client |
Character limit (max_length) | 10000 characters, hard-coded |
| OAuth scopes | read, identity, submit, flair |
| Redirect URI | {FRONTEND_URL}/integrations/social/reddit |
| API host | https://oauth.reddit.com (override REDDIT_BASE_URL) |
| OAuth / token host | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
REDDIT_CLIENT_ID | Yes | — | Client id shown under the app name at reddit.com/prefs/apps |
REDDIT_CLIENT_SECRET | Yes | — | App secret shown on the app card |
REDDIT_BASE_URL | No | https://oauth.reddit.com | API origin for /api/v1/me, /api/submit, subreddit lookups, media assets, comments. A trailing slash is stripped. |
REDDIT_WEB_BASE_URL | No | https://www.reddit.com | Origin used for /api/v1/authorize and /api/v1/access_token, and to build release URLs. A trailing slash is stripped. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the OAuth redirect URI. Trailing slashes are stripped. |
JWT_SECRET | Yes in practice | empty | Encrypts 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
- Sign in to Reddit with the account that will own the app.
- Open https://www.reddit.com/prefs/apps (or
https://old.reddit.com/prefs/apps). - Scroll to Developed Applications and click create another app / are you a developer? create an app.
- Fill in:
- name — e.g.
RustizorMyRustizInstance. - 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) orscript(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.
- name — e.g.
- 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
| Scope | Why Rustiz needs it |
|---|---|
read | /subreddits/search, /r/{name}/about, /user/{name}/submitted, post requirements and flair listings |
identity | GET /api/v1/me during connect (id, name, avatar) |
submit | POST /api/submit and POST /api/comment |
flair | Reading 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.
- 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.
- 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 privater/rustiz_test) so a bad post cannot hit a real community. Reddit’s site-wide rules and each subreddit’s own rules still apply. - 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.
- A successful OAuth consent is not API approval. Without approved API
access, the consent page may work while
oauth.reddit.comreturns401,403or throttled traffic. - 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.
- 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;
--openlaunches 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--timeoutseconds (default300) for the channel to appear. - With a public HTTPS
FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll withrustiz channels connect-status <connectionId>orrustiz channels list. - On success the JSON document contains
connected: trueand the new channel’sid,identifier,name,profile,pictureandrefreshNeededfields.
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 genericconnectpath would base64-encode the object and pass it as the OAuthcode, which Reddit will reject. Reddit is OAuth only.externalUrl— Reddit is not self-hosted. The generic path calls the provider’sexternal_url(), which returnsUnsupported("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
subredditmust be an array with at least one entry.- Every entry must have a
valueobject. value.subredditmust be at least 2 characters. The error says “Subreddit must start with /r”, but the code only checks length.rustand/r/rustboth pass.value.titlemust be at least 2 characters.value.typemust be one ofself,link,media.- For
type: "link", ifurlis present it must parse ashttp/https, or contain the literal(post:. A missingurlkey is not rejected (the check is skipped); an empty string is rejected asInvalid URL. - If
is_flair_requiredis truthy,flair.idandflair.namemust both be non-empty. - If
is_flair_requiredis false, an attachedflairis still sent if present.
check_validity() rules
Two pre-flight errors are returned as strings:
- 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 usesposts.first()only, so it is really “exactly one media in the main post”, not per subreddit. - If any post in any group has an
.mp4path 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
| Type | Accepted by the provider | Notes |
|---|---|---|
type: "self" | No media needed | Text post; media attached to the engine post is ignored |
type: "link" | No media needed | url is sent as the link destination |
type: "media", image | Exactly 1 image | Uploaded to Reddit’s S3 asset endpoint, then submitted as kind=image |
type: "media", video | Exactly 1 .mp4 | Submitted as kind=video; a thumbnail is mandatory and uploaded as video_poster_url |
The upload flow is:
POST {REDDIT_BASE_URL}/api/media/assetwith multipart fieldsfilepathandmimetype.- Reddit returns
args.action(often protocol-relative,//…) andargs.fields. - Rustiz re-uploads the file as multipart to the S3 action and reads the
<Location>element from the XML response. - The returned URL is sent as
urlin/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 inpending_data. It does not call Reddit.check_post_status()arms the next subreddit, andfinalize_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()writesarmed: {sr, title, armedAt, media, submitted, lookups}before the workflow commits the state and callsfinalize_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()callsGET /user/{username}/submitted?limit=25&sort=newon the API host, then matches subreddit, exact title andcreated_utc > armed_at - 60s. Only 25 newest submissions are inspected. Legacy rows withoutintegration.profilecannot 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 aBadBodywithReddit rejected the post to r/<sr>: <messages>. - Blocking compatibility path.
post()runspost_pending → check → finalizein a loop with 5-second sleeps and an 8-minute timeout. The engine uses the pending path becausesupports_pending = true; the blocking path is kept for pre-v1.0.6 workflows. - Malformed settings can complete empty.
post_pending()deserializessettings.subredditwithunwrap_or_default(). A caller that bypassesvalidate_settings()and sends a non-array value gets an empty subreddit list;check_post_status()then seescursor >= lenand reportsCompletedwith emptypostId/releaseURL. Always validate settings before creating a post. - Multi-subreddit result shape.
completed_response()joinspost_ids andreleaseURLs with commas. A media post whose lookup budget expired contributes an emptypost_idbut 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 = 1to 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 acode_verifierfor 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()postsgrant_type=refresh_tokenwith the same Basic auth, calls/api/v1/meagain, and keeps the same refresh token. It does not callcheck_scopes(); a token whose scopes were reduced is only discovered when a missing endpoint returns an error. - Comment thread is flat.
last_comment_idis ignored. If a product requirement is “reply to the previous comment”, this provider does not do it. - Comment permalink can collapse.
release_urlis{REDDIT_WEB_BASE_URL}{permalink}. If Reddit omitspermalink, the URL becomes the bare web origin. restrictionspath bug. It derives the subreddit name withsubreddit.split_once("/r/")and falls back to the literal"undefined". Passingr/rust(no leading slash) therefore calls/api/v1/undefined/post_requirements. Always pass/r/<name>.subredditshides image-only communities. Search results withsubmission_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 withtype: "link"and nourlpassesvalidate_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.
restrictionsreportsis_flair_requiredas 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 absoluteaction; the code accepts absolute URLs for testability and prefixes protocol-relative URLs withhttps:. .mp4check is split.check_validity()checks lowercasemp4;finalize_post()uses case-insensitivehas_extension. An uppercase.MP4passes pre-flight but then requires a thumbnail and takes the video branch.- Reddit 401 handling.
Classifyis a no-op, so the shared HTTP layer maps a401toRefreshTokenand the engine refreshes once. A second failure flags the channel for reconnection. Reddit’s403(no API approval, banned subreddit, missing scope) is aBadBody, 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
- Reddit app registration: https://www.reddit.com/prefs/apps
- OAuth2 (archived but still the flow Reddit uses): https://github.com/reddit-archive/reddit/wiki/OAuth2
- Responsible Builder Policy: https://support.reddithelp.com/hc/articles/42728983564564
- Data API wiki / rate limits: https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki
- Developer Platform (Devvit): https://developers.reddit.com/docs
- 2026 migration announcement: https://www.reddit.com/r/redditdev/comments/1wubcvf/moving_data_api_apps_to_the_developer_platform/
POST /api/submitreference: https://www.reddit.com/dev/api#POST_api_submit- Postiz Reddit setup: https://docs.postiz.com/providers/reddit
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
| Property | Value |
|---|---|
| Identifier | slack |
| Display name | Slack |
| Connection mode | oauth (a human installs the app into a workspace) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are replies with thread_ts |
| Pending / async publishing | No. supports_pending = false; one chat.postMessage per post |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 10 — 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 scopes | channels: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 host | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
SLACK_ID | Yes | — | Slack Client ID (not SLACK_CLIENT_ID) |
SLACK_SECRET | Yes | — | Slack Client Secret (not SLACK_CLIENT_SECRET) |
SLACK_BASE_URL | No | https://slack.com | Origin for OAuth and /api/... calls. Trailing slash stripped. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI; also decides whether the redirectmeto.com tunnel is used |
JWT_SECRET | Yes in practice | empty | Encrypts 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
- Open https://api.slack.com/apps and click Create New App.
- Choose From scratch.
- 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. - 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
refreshquery parameter,generate_auth_url()uses the redirect URI without?refresh=<id>(it callsredirect_uri(None)), whileauthenticate()callsredirect_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 returnsbad_redirect_uriduring 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:
| Scope | Why Rustiz needs it |
|---|---|
channels:read | conversations.list for public channels |
groups:read | conversations.list for private channels |
chat:write | chat.postMessage |
users:read | users.info to resolve the bot user’s name/avatar during connect |
channels:join | conversations.join before posting to a public channel |
chat:write.customize | username 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.comprefix 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_inof 100 years) and itsrefresh_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;
--openopens the browser. - With a local
FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to--timeoutseconds (default300) for the channel to appear. - With a public HTTPS
FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll withrustiz channels connect-status <connectionId>orrustiz 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’sexternal_url(), which returnsUnsupported("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
channelmust 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
mp4is caught. A.MOVor.WebMfile 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.
| Requirement | Detail |
|---|---|
| Image host | image_url must be publicly reachable by Slack’s servers. A local upload path or a URL behind authentication renders as a broken image. |
| Image formats | Slack supports PNG, JPG/JPEG and GIF. WebP, SVG, BMP and TIFF are not supported by image blocks. |
| URL length | image_url maximum is 3000 characters. |
alt_text | Required by Slack; Rustiz sends an empty string. It is accepted but poor for accessibility. |
| Blocks per message | Maximum 50 blocks. Rustiz emits one section block plus one image block per media item, so more than 49 images exceeds the block limit. |
| Video | Not supported. The early check only catches lowercase .mp4; other video containers slip through. |
| Text | The 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()computesthread_ts = last_comment_id.unwrap_or(post_id).- For the first reply,
last_comment_idisNone, sopost_id(the original messagets) becomes the thread parent. - For later replies,
last_comment_idis the previous comment’spost_id, which is also a valid Slack messagets. Slack accepts any message in the thread asthread_ts, so a multi-entry post forms a normal Slack thread. - Each reply sends the same Block Kit structure and its own media.
releaseURLcomes fromchat.getPermalinkfor 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_lengthis misleading. It returns400000, but the message is always sent inside a Block Kitsectionblock, whose text limit is 3000 characters. A 3500-character post passes Rustiz validation and is rejected by Slack withinvalid_blocks/msg_blocks_too_long. If you need longer content, use a snippet/file upload, which this provider does not implement.chat:write.customizeis required, despite the docs. The publish payload always includesusername(integration.name) and, when present,icon_url. Slack only permits those overrides withchat: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 likehttp://my-https-proxy.localis treated as HTTPS and sent directly to Slack, which then rejects it because the scheme ishttp. KeepFRONTEND_URLeither a realhttps://URL or a plainhttp://localhost:…URL. - Local redirect URL. For
FRONTEND_URL=http://localhost:5000, registerhttps://redirectmeto.com/http://localhost:5000/integrations/social/slackin Slack. Registering the plainhttp://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 withbad_redirect_uri, start a new connection instead of using the refresh flow. conversations.joinfailure is ignored.post()callsPOST /api/conversations.joinand discards the response, then posts. For a public channel where the bot is not a member,channels:joinusually fixes it. For a private channel, the bot must be manually invited;channels:joincannot join private channels. The subsequentchat.postMessagethen fails withnot_in_channel.- Permalink failure after a successful post can duplicate.
publish()sends the message, then callschat.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()callsconversations.list?types=public_channel,private_channelwith nolimitand nocursor. Slack’s default limit is 100; larger workspaces only see the first page. Archived channels are included (exclude_archiveddefaults to false), and posting to an archived channel fails. - Only text and images.
chat.postMessageaccepts 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_textis 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()storesrefresh_token: Some("null")(the literal string) andexpires_in: Some(100 * 365 * 24 * 3600).refresh_token()returns an empty access token andexpires_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:falseaware. Slack returns HTTP 200 with{"ok":false,"error":"…"}for most failures.check_api_error()mapsinvalid_auth,token_revoked,token_expiredandaccount_inactiveto a refresh error;ratelimitedbecomes a plain error; everything else becomesBadBody. A 200 response is not retried by the shared HTTP layer, so aratelimitedbody on a 200 is not retried. - Rate limits. Slack’s
chat.postMessageallows about 1 message per second per channel, with a workspace-wide limit of several hundred messages per minute.max_concurrent_job = 10can put multiple posts into the same channel at once; the 1/sec channel bucket still applies. Slack returns HTTP 429 withRetry-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 returnsteam: nullfor an org-level installation (is_enterprise_install: true) and puts the id in theenterpriseobject. Rustiz does not readenterprise.id, so the integration’sinternal_idcan be empty. Install the app per workspace unless you have tested the org-wide flow. change_nickname()andchange_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_URLaffects release URLs.chat.getPermalinkreturns a Slack-hosted URL, so release URLs stay correct even with a mock base; message IDs and channel ids are real Slack values.
References
- Slack app management: https://api.slack.com/apps
- Installing with OAuth: https://docs.slack.dev/authentication/installing-with-oauth
chat.postMessage: https://docs.slack.dev/reference/methods/chat.postMessageconversations.list: https://docs.slack.dev/reference/methods/conversations.list- Section block (3000-character limit): https://docs.slack.dev/reference/block-kit/blocks/section-block
- Image block (public URL, formats): https://docs.slack.dev/reference/block-kit/blocks/image-block
- Rate limits: https://docs.slack.dev/apis/web-api/rate-limits
- App distribution: https://docs.slack.dev/app-management/distribution
- Marketplace review: https://docs.slack.dev/slack-marketplace/slack-marketplace-review-guide/
- Postiz Slack setup: https://docs.postiz.com/providers/slack
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
| Property | Value |
|---|---|
| Identifier | telegram |
| Display name | Telegram |
| Connection mode | telegramBot (human sends a bot command; no browser OAuth) |
| Editor | html |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are replies with reply_to_message_id |
| Pending / async publishing | No. supports_pending = false; one Bot API call (or media group sequence) per post |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 — conservative against Bot API limits |
Character limit (max_length) | 4096 characters for text; media captions are limited to 1024 |
| API host | https://api.telegram.org (override TELEGRAM_BASE_URL) |
| OAuth scopes | None |
| Redirect URI | None |
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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
TELEGRAM_TOKEN | Yes | — | Bot API token from BotFather, format <bot_id>:<secret> |
TELEGRAM_BOT_NAME | No | unset | Bot username without @, used only in the connect instructions (@name). Not used for API calls. |
TELEGRAM_BASE_URL | No | https://api.telegram.org | Origin for all Bot API calls. Trailing slash stripped. Useful for a local Bot API server. |
STORAGE_PROVIDER | No | local | When local, media URLs beginning with FRONTEND_URL are converted to local upload paths before sending. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Used to recognise local media URLs and in the generic OAuth state machinery (not for Telegram’s redirect). |
JWT_SECRET | Yes in practice | empty | Encrypts 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
- Open Telegram and start a chat with @BotFather.
- Send
/newbot. - Choose a display name and a username ending in
bot(e.g.MyRustizBot/MyRustizBot_bot). - BotFather returns an API token such as
123456789:AAH…. This isTELEGRAM_TOKEN. Treat it like a password. - Optionally send
/setdescription,/setabouttext,/setuserpicand/setcommandsto 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:
- Chat with @BotFather.
- Send
/mybots, select the bot. - Bot Settings → Group Privacy → Turn off (“Privacy mode is disabled”).
- 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/connectcommand and the bot’s “Connection Successful” notice - media sending permissions
- Post messages (
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; usecustomFields.chatIdor the/connectword instead.customFieldswith achatIdwhen the bot is not already in that chat —getChatfails. Add the bot first, or use the word flow.providerwhen polling — onlyconnectionIdis 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:
| Extension | Kind | Bot API method |
|---|---|---|
png, jpg, jpeg, gif, webp, bmp, svg, tiff, tif, heic | photo | sendPhoto (single) / sendMediaGroup |
mp4, m4v, mov, webm, mkv, avi, mpeg, mpg | video | sendVideo / sendMediaGroup |
| anything else | document | sendDocument / sendMediaGroup |
The send path depends on the number of media items:
- 0 media:
sendMessagewithparse_mode=HTML. - 1 media:
sendPhoto,sendVideoorsendDocument, with the text as the caption andparse_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 withFRONTEND_URLis 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_mediafails with a local media error.
Telegram’s own limits:
| Limit | Value |
|---|---|
| Text message | 1–4096 characters after HTML entity parsing |
| Media caption | 0–1024 characters |
| Upload via multipart | 10 MB for photos, 50 MB for other files |
| Send by URL | 5 MB for photos, 20 MB for other content |
sendMediaGroup items | 2–10 per group |
getFile download | 20 MB |
| Messages per second in one chat | about 1; short bursts tolerated |
| Messages per minute in a group | 20 |
| Global bot broadcast | about 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:
<strong>→<b>and</strong>→</b><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()computesreply_to = last_comment_id.unwrap_or(post_id), parses it asi64, drops0, and sends the message withreply_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_toisNoneand 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_TOKENis updated and the processes restart. - The bot token leaks through the integration picture.
authenticate()resolves the chat photo withgetFileand stores the returned link as the integration’spicture:https://api.telegram.org/file/bot<TELEGRAM_TOKEN>/<file_path>. That URL is returned byintegrationList/channels listand 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. /connectmatching 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
getUpdatesconsumer. A webhook or a second poller causes409 Conflict. CalldeleteWebhookbefore 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/connectmessage. - Pending state is one hour. The
tg_…connection id is stored inoauth_stateswithTELEGRAM_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. lastChatIdis an update offset. Do not read it as a chat id. It is fed back togetUpdatesasoffset, which confirms all earlier updates.- Connect can succeed without admin rights.
get_bot_id()checksgetChatMemberand 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. Beingadministratororcreatoris not enough; the code also requiresresult.can_delete_messages == true. Otherwise it leaves the messages and asks the human to remove them. getChatfailure is fatal.authenticate()callsgetChatwith 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()storesid = chat.usernamewhen the chat has a public username, otherwiseid = chat_id. The access token is always the numeric chat id. Posting uses the token, so both work. The storedinternal_idis used for the release URL and for diagnostics. - Private-chat release URLs are wrong.
release_link()useshttps://t.me/{internal_id}/{message_id}wheneverinternal_idis non-empty and not"undefined". For a private channel or group without a username,internal_idis the negative chat id (e.g.-1001234567890), producinghttps://t.me/-1001234567890/42, which is not a valid Telegram link. Thehttps://t.me/c/<id>/<message>fallback only runs for legacy rows whoseinternal_idis empty or"undefined". Public channels/groups with a username get correctt.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 callssendMediaGroupfor 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.
sendMediaGroupallows photos and videos together, but documents/audio can only be grouped with the same type. The provider does not group bykind; 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 withFRONTEND_URLis converted to a local path; anything that does not start withhttpis validated withlocal_media_path(..., "telegram-error"). A path outsideUPLOAD_DIRECTORYfails 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 contentto a friendly error but cannot fix an unreachable URL. - No media retry. Text and single-part calls use
Http::send, which retries429/5xx.multipart()usesexecutedirectly and does not retry; a transient429or5xxon an upload becomes aBadBody(often with “Unknown Error”). Re-run the post if the media is safe to duplicate. handle_errors()only handles HTTP 400. It parseserror_codeanddescription; any non-400 status is left to the shared HTTP layer. A401(invalid bot token) therefore becomes aRefreshTokenerror by the generic rule, butrefresh_token()returns an empty token, so the channel is flagged for reconnection even though the only fix is to correctTELEGRAM_TOKEN.- No real token refresh.
refresh_token()returnsexpires_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 theurlfield. The normal/integrations/social/telegramOAuth start route is not the supported path; useconnectChannelTool,rustiz channels connect telegram, orPOST /public/v1/integrations/telegram/connect, all of which go throughconnect::start()’s special case.is_web3 = trueis cosmetic. It is an upstream class flag; the connect layer special-cases Telegram anddescribe()reportstelegramBot.PostResponse::done()reportscompleted, notposted. Both Discord and Slack setPostStatus::Posted; Telegram uses the helper that setsPostStatus::Completed. Both are terminal for the engine, but a consumer that distinguishes the two states will see Telegram ascompleted.- 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 = 3is conservative but a queue that targets one channel can still hit the per-chat limit. Telegram returns429withparameters.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_idis 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
- BotFather / bot creation: https://core.telegram.org/bots/features#botfather
- Bot API: https://core.telegram.org/bots/api
getUpdatesand webhooks (mutually exclusive): https://core.telegram.org/bots/api#getting-updates- Privacy mode and which updates a bot sees: https://core.telegram.org/bots/features#privacy-mode
- Sending files and size limits: https://core.telegram.org/bots/api#sending-files
sendMediaGroup(2–10 items): https://core.telegram.org/bots/api#sendmediagroupsendPhoto(caption 1024): https://core.telegram.org/bots/api#sendphotosendMessage(text 4096): https://core.telegram.org/bots/api#sendmessage- Bot FAQ / rate limits: https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this
- Postiz Telegram setup: https://docs.postiz.com/providers/telegram
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
| Property | Value |
|---|---|
| Identifier | threads |
| Display name | Threads |
| Connection mode | oauth |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries are published as replies with reply_to_id |
| Pending / async publishing | Yes. supports_pending = true; containers are created, polled, then published |
| Analytics | Yes. supports_analytics = true; threads_insights |
| Post analytics | Yes. supports_post_analytics = true; /{post-id}/insights |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 20 (“Threads limits are per profile”) |
Character limit (max_length) | 500 characters |
| API base | https://graph.threads.net (THREADS_BASE_URL) |
| Web base | https://www.threads.net (THREADS_WEB_BASE_URL) |
| Token refresh | Yes. 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
THREADS_APP_ID | Yes | — | The Threads App ID from App Dashboard → Threads API → Settings. This is not the Facebook app ID. |
THREADS_APP_SECRET | Yes | — | The Threads App Secret from the same panel. It should be 32 characters; the UI truncates it unless you scroll. |
THREADS_BASE_URL | No | https://graph.threads.net | Graph host for token exchange, /v1.0/me, containers, publish, insights. |
THREADS_WEB_BASE_URL | No | https://www.threads.net | Origin of /oauth/authorize and of the fallback profile URL. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI, wrapped in redirectmeto.com when not HTTPS. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global upload guard. Threads fetches media from a public URL. |
Provider visibility warning from Postiz: if
THREADS_APP_IDorTHREADS_APP_SECRETis missing, the provider does not appear in the Add Channel list at all. Restart the process after adding them.rustiz providers --statusreports the missing variables directly.
Step-by-Step Developer App Setup
1. Create the app with the Threads use case
- Go to https://developers.facebook.com/apps/creation/.
- Create an app and choose Access the Threads API (the Threads use case).
- 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.
- Finish creating the app.
2. Configure API access
- On the app dashboard, open Access the Threads API.
- Add
threads_content_publish.threads_basicis selected automatically. Addthreads_manage_replies(reply/comments) andthreads_manage_insights(analytics) for the full Rustiz feature set. - 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)
- Threads App ID →
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_URL | Registered redirect URI |
|---|---|
https://social.example.com | https://social.example.com/integrations/social/threads |
http://localhost:5000 | https://redirectmeto.com/http://localhost:5000/integrations/social/threads |
http://localhost:4200 | https://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
- Return to the app dashboard and click Finish customization; answer “Yes, I’m finished”. The API may not work until this wizard is complete.
- Restart
rustiz serve,rustiz workerandrustiz mcpso 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 [].
| Scope | Why Rustiz needs it | App Review for non-testers |
|---|---|---|
threads_basic | /me, all endpoints | Yes |
threads_content_publish | Create/publish containers | Yes |
threads_manage_replies | Post replies/comments | Yes |
threads_manage_insights | Account and post insights | Yes |
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
- App Dashboard → App roles → Roles → Testers → Add People.
- Under Additional Roles for this App, choose Threads Tester.
- Enter the Threads username (not the Facebook/Meta developer account).
- On threads.com, open Settings → Account → Website permissions → Invites (https://www.threads.com/settings/website_permissions) and accept.
- Test the connection. The Open Graph API Explorer can generate a Threads
access token for the tester as a sanity check (select
threads.netv1).
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
customFieldsroutes toconnect_custom()and is treated as an OAuth code. Never send it tothreads.externalUrlis 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:triggerToolhas no Threads-specific helper to call (no page, board or channel picker).settingsis 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:
| Media | Rule |
|---|---|
| Image | JPEG or PNG, ≤ 8 MB, width 320–1,440 px, aspect ratio ≤ 10:1, sRGB |
| Video | MOV or MP4, H.264/HEVC + AAC, 23–60 fps, ≤ 1 GB, 5 minutes max, width ≤ 1,920 |
| Carousel | 2–20 images/videos; a carousel counts as one post |
| Text | ≤ 500 characters |
| Links | At most 5 unique URLs per post; more fails with THREADS_API__LINK_LIMIT_EXCEEDED |
| Alt text | Not sent by Rustiz (MediaContent.alt is ignored) |
How the containers are built:
- No media →
POST /v1.0/{user-id}/threadsasmultipart/form-datawithmedia_type=TEXT,text,access_token, optionalreply_to_idandquote_post_id. - One media →
POST /v1.0/{user-id}/threads?image_url=…orvideo_url=…, plusmedia_type=IMAGE|VIDEO,text,access_token.is_carousel_itemis omitted, which means the API treats it as a single post. - More than one media → one child container per item with
is_carousel_item=trueandmedia_type=IMAGE|VIDEO, then the parentmedia_type=CAROUSEL&children=<ids>&text=….
Child text quirk: every carousel child is created with the full post text as its
textparameter, not just the parent. Threads documentstextas 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:
- The first entry is published as a text/image/video/carousel post.
- Each later entry is created with
reply_to_idset to the previous post id (or the root id for the first reply) and published withthreads_publish. comment()usescreate_thread_content()thenpublish_thread(), which waits for the container to beFINISHED(orPUBLISHED) and then publishes.- Replies count against the profile’s 1,000 replies/24 h limit.
Plugs
| Method | Kind | Fields | Effect |
|---|---|---|---|
autoPlugPost | @Plug (run_plug) | likesAmount, post | Reads 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 onIN_PROGRESS.finalize_post()creates the carousel parent (returning Pending again), then callsthreads_publishonce the parent is ready. PUBLISHEDmeans “already live”. Ifcheck_post_status()sees a container inPUBLISHED, 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 aFINISHEDparent 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/confirmedhandshake. Duplicate protection relies on the container status: aPUBLISHEDcontainer is never published again. A crash afterthreads_publishbut 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 usingerror_messageunless it is empty orUNKNOWN; anything else → in progress. A missingstatusis treated as in-progress until the budget runs out. - Authorize URL omits
response_type=code. Meta’s Threads documentation listsresponse_type=codeas required on the authorization window. The Rust URL sendsclient_id,redirect_uri,stateandscopeonly. The endpoint currently defaults to the code flow, but if the login window rejects the request, add&response_type=codetogenerate_auth_url(). - Token exchange uses GET, not the documented POST form. Meta’s reference
shows
POST https://graph.threads.com/oauth/access_tokenwith form fields; Rustiz calls the same endpoint with query parameters viac.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_insightsconnects 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 callsGET /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. /mehas noname.fetch_user_info()requestsid,username,threads_profile_picture_urland setsname = username. The channel display name is therefore the handle, not a display name.refresh_tokeninAuthTokenDetailsis the access token. There is no separate refresh token in the Threads API.expires_inis 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 querythreads_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 returnstotal_value, one point is emitted dated today; otherwise it readsvalues[]and dates them fromend_time. Post analytics usesGET /v1.0/{post-id}/insights?metric=views,likes,replies,reposts,quotesand prefersvalues[0].value, falling back tototal_value.value. All errors are swallowed and return[]; percentage change is5.0(account) or0.0(post). threads_insightsvsinsights. The account endpoint isthreads_insights; the per-post endpoint isinsights. Mixing them returns a permission/unknown-metric error.- Release URL fallback.
thread_permalink()readsGET /v1.0/{thread-id}?fields=id,permalink. If that fails, it returnshttps://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. A429falls through to the generic retry policy; there is noX-Business-Use-Case-Usagebackoff. - 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 byhandle_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_IDis 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
- Threads API get started: https://developers.facebook.com/docs/threads/get-started
- Threads posts (single, carousel, media specs): https://developers.facebook.com/docs/threads/posts
- Threads API overview and rate limits: https://developers.facebook.com/docs/threads/overview
- Long-lived tokens and refresh: https://developers.facebook.com/docs/threads/get-started/long-lived-tokens
- App modes: https://developers.facebook.com/docs/development/build-and-test/app-modes
- App Review: https://developers.facebook.com/docs/app-review
- Postiz Threads setup: https://docs.postiz.com/providers/threads
- Postiz Threads settings: https://docs.postiz.com/public-api/providers/threads
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_ONLYand 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). WhenFRONTEND_URLis not HTTPS the login goes throughredirectmeto.com, and that relayed URL must be registered byte-for-byte.
Overview
| Property | Value |
|---|---|
| Identifier | tiktok |
| Display name | Tiktok (the display string in ProviderInfo) |
| Connection mode | oauth (authorization code; no PKCE challenge) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | Yes. supports_pending = true; post_pending → check_post_status; no finalize_post |
| Analytics | Yes. supports_analytics = true; profile counts plus the last 20 videos |
| Post analytics | Yes. supports_post_analytics = true; views/likes/comments/shares for one video |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | true — PNG uploads are converted to JPEG by the engine before publishing |
max_concurrent_job | 10000 (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) |
| API | https://open.tiktokapis.com (v2) |
| OAuth scopes | video.list, user.info.basic, video.publish, video.upload, user.info.profile, user.info.stats |
| Token lifetime | 24 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"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
TIKTOK_CLIENT_ID | Yes | none | Developer app Client key (16 characters in Postiz’s docs). |
TIKTOK_CLIENT_SECRET | Yes | none | Developer app Client secret (32 characters in Postiz’s docs). |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI; also decides whether redirectmeto.com is prefixed. Trailing / stripped. |
TIKTOK_BASE_URL | No | https://open.tiktokapis.com | API 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_URL | No | https://www.tiktok.com | Authorize 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
- Sign up / log in at https://developers.tiktok.com/.
- Create or join an organization (recommended; an individual-owned app is allowed but not recommended for production).
- Open https://developers.tiktok.com/apps → Connect an app, choose the owner organization.
- 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.
- 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.basicby 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 hasvideo.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.comis 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 containinghttpswould 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_URLvideo, 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:
- On the app page, toggle Production → Sandbox.
- Create Sandbox (up to 5 sandboxes), optionally cloning the production configuration. Edit app details/products in the sandbox and Apply changes.
- 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.
- Authorize each target user through Login Kit in the sandbox.
- 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:
- 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.
- 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.
- unaudited clients may only publish
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()
| Field | Type | Required | Behavior |
|---|---|---|---|
title | string (≤ 90) | No | Photo 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_level | enum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY | Yes | Only when DIRECT_POST. Defaults to PUBLIC_TO_EVERYONE at publish time if missing, but validation requires a value. |
duet | boolean | Yes | Videos only, DIRECT_POST only. Sent as disable_duet = !duet. Ignored for photos. |
stitch | boolean | Yes | Videos only, DIRECT_POST only. Sent as disable_stitch = !stitch. Ignored for photos. |
comment | boolean | Yes | Both media types, DIRECT_POST only. Sent as disable_comment = !comment. |
autoAddMusic | enum "yes", "no" | Yes | Photos only, DIRECT_POST only. "yes" sends auto_add_music: true (TikTok picks recommended music). Ignored for videos. |
brand_content_toggle | boolean | Yes | Paid-partnership disclosure; DIRECT_POST only. |
brand_organic_toggle | boolean | Yes | Own-business promotion disclosure; DIRECT_POST only. |
video_made_with_ai | boolean | No | Videos 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). |
music | object {id, title?, artist?, image?, audio_volume?, video_volume?} | No | Declared for TikTok Business only. Legacy tiktok.rs never reads it. music.id must be non-empty if the object is present. |
location | object {id, name, address?} | No | Declared for TikTok Business only. Legacy tiktok.rs never reads it. id and name must be non-empty. |
content_posting_method | enum DIRECT_POST, UPLOAD | Yes | DIRECT_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, somusicandlocationappear here even though only the Business provider implements them. Passing them totiktokis 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_levelis required and must be one of the four enum values.duet,stitch,comment,brand_content_toggle,brand_organic_toggleare required booleans (missing or wrong type → error).video_made_with_ai, if present and non-null, must be a boolean.autoAddMusicis required and must be exactly"yes"or"no".content_posting_methodis required and must be"DIRECT_POST"or"UPLOAD".music, if present and non-null, must be an object with a non-empty stringid;title/artist/imagemust be strings when present;audio_volume/video_volumemust be numbers (or numeric strings) in0..=100.location, if present and non-null, must be an object with non-empty stringidandname;addressmust 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-nothingpicture_size_check_failedbefore 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.mp4is treated as a photo. - Local files and public URLs both work; the bytes are streamed from storage.
source_infoisFILE_UPLOADwithvideo_size,chunk_size,total_chunk_count:- ≤ 64 MiB → one chunk (
chunk_size = video_size, count1); -
64 MiB → 10 MiB chunks and
count = size / 10 MiB(floor). The final chunk absorbs the remainder and may exceedchunk_size(TikTok allows up to 128 MiB for the last part).
- ≤ 64 MiB → one chunk (
- Upload
PUTrequests sendContent-Type: video/mp4,Content-LengthandContent-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 returnspendingand lets the status endpoint decide, because TikTok may already have all the bytes. video_cover_timestamp_msis sent when the media carries athumbnailTimestamp.- 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_imagesis the list of media paths as stored (typically your public upload/CDN URLs),photo_cover_index: 0,source: "PULL_FROM_URL". post_modeisDIRECT_POSTorMEDIA_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 = truerewrites PNG uploads to JPEG first. is_aigcis not sent for photos.
Endpoint selection
content_posting_method | Media | Endpoint |
|---|---|---|
DIRECT_POST | video | POST /v2/post/publish/video/init/ |
DIRECT_POST | photo | POST /v2/post/publish/content/init/ |
UPLOAD | video | POST /v2/post/publish/inbox/video/init/ |
UPLOAD | photo | POST /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": ... }:
| Status | Result |
|---|---|
SEND_TO_USER_INBOX | Completed 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_COMPLETE | If 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. |
FAILED | BadBody, 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_ONLYandMUTUAL_FOLLOW_FRIENDSimmediately returnUnavailable(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 withid,like_count,comment_count,share_count,view_countand 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_URLis 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. Ifredirectmeto.comis down, local/HTTP connects fail. Set an HTTPSFRONTEND_URLto remove the dependency. - Redirect URI substring check.
frontend.contains("https")decides the relay; it is not a URL parse. AFRONTEND_URLsuch ashttp://https.localhost:5000would skip the relay and TikTok would reject the URI. expires_inis hard-coded to 23 h. TikTok access tokens live 24 h. Sincerefresh_cronis false, there is no proactive refresh; the first API call after expiry fails withaccess_token_invalid, the provider maps it toRefreshToken, the engine refreshes and retries. Refresh tokens rotate; the new one is stored.open_idis normalized by removing dashes. TikTok open ids are app-scoped: the same TikTok account connected through a different client key gets a differentopen_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.listis requested even for posting. If TikTok grantsvideo.publish/video.uploadbut notvideo.list,check_scopesrefuses the connection. This is deliberate (analytics andmissingneed it), but it makes scope approval stricter than a publish-only integration.settings.titleis mostly a photo field. For videos the title is the post message, so a >90-chartitlesetting fails validation for nothing. For photos it is truncated to 90, while the description (the message) is capped bymax_length = 2000, below TikTok’s 4000.musicandlocationare dead settings on this provider. They exist in the shared schema buttiktok.rsnever reads them. Usetiktok-businessfor commercial music and location tags.autoAddMusiconly applies to photos. For videos the field is validated but never sent.video_made_with_aionly applies to videos. The photo endpoint accepts a top-levelis_aigc, which this port does not send.UPLOADis not publishing. It creates an inbox draft that the user must finish in the TikTok app within 24 h. The status becomesSEND_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_DIRECTORYfile 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 ofphoto_pull_failed/url_ownership_unverified. - Validation is shallow. No file size, duration, fps, resolution (video) or
MIME check beyond the
.mp4substring and the photo shorter-side rule. TikTok rejects the publish later withfile_format_check_failed,duration_check_failed,frame_rate_check_failed, etc.;handle_errorsmaps those to readable messages. spam_risk_too_many_pending_sharemeans 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 toDisconnect, which cannot actually help; reconnecting does not reset the cap.post_urldepends onintegration.profile. It must contain the TikTok username (set fromuser/infoat connect time). If it is empty, release URLs becomehttps://www.tiktok.com/@/video/....- The public-id regex relies on a TikTok typo. It matches
"publicaly_available_post_id"(missing the secondl) in the raw response, exactly as TikTok emits it, because JSON numbers in that array can exceed2^53and lose precision if parsed normally. - Error identifier typo. The
FAILEDstatus branch usesProviderError::bad_body("titok-error-upload", ...)(missing at), 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 blockingpost()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_pendingsends the configuredprivacy_leveldirectly; it does not call/v2/post/publish/creator_info/query/and does not check the account’sprivacy_level_options,comment_disabled,duet_disabledormax_video_post_duration_sec. TikTok’s Direct Post UX guidelines require that query, and the API returnsprivacy_level_option_mismatchwhen the chosen level is not offered. The only caller ofcreator_info/queryis the unexposedmax_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 returnsmax_video_post_duration_sec, but TikTok does not overridefunctions(), sotriggerToolcannot invoke it. It is only reachable from Rust code.- Length checks count Unicode scalar values, not UTF-16 code units. The
engine’s
max_lengthcheck andtake_charscountchars, while TikTok’s photo title limit is 90 UTF-16 runes. A title made of emoji (2 code units each) can passvalidate_settingsat 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_jpegonly rewrites PNGs.prepare_post_mediaconverts PNG files to JPEG and re-stores them whenUPLOAD_DIRECTORYis 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
- Register an app: https://developers.tiktok.com/doc/getting-started-create-an-app
- Add a sandbox / target users: https://developers.tiktok.com/doc/add-a-sandbox
- Scopes reference (video.list / video.publish / video.upload / user.info.*): https://developers.tiktok.com/doc/tiktok-api-scopes
- Login Kit for Web (redirect URI rules, authorize URL, token exchange): https://developers.tiktok.com/doc/login-kit-web
- Content Posting API — Direct Post: https://developers.tiktok.com/doc/content-posting-api-get-started
- Content Posting API — Photo: https://developers.tiktok.com/doc/content-posting-api-reference-photo-post
- Media transfer guide (chunks, PULL_FROM_URL, URL ownership, restrictions): https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide
- Content Posting API audit: https://developers.tiktok.com/application/content-posting-api
- App review FAQ: https://developers.tiktok.com/doc/getting-started-faq
- Postiz TikTok guide: https://docs.postiz.com/self-host/providers/tiktok
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | tiktok-business |
| Display name | TikTok (the ProviderInfo name is literally “TikTok”; use the identifier to tell it apart) |
| Connection mode | oauth (authorization code; no PKCE) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | Yes. supports_pending = true; post_pending → check_post_status; no finalize_post |
| Analytics | Yes. supports_analytics = true; profile counts plus the last 20 videos |
| Post analytics | Yes. supports_post_analytics = true; one video’s views/likes/comments/shares |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | true |
max_concurrent_job | 10000 |
Character limit (max_length) | 2200 characters (Business video caption; photo captions allow 4000, but the port uses 2200 for both) |
| API | https://business-api.tiktok.com/open_api/v1.3 |
| OAuth scopes | user.info.basic, user.info.username, user.info.stats, video.list, video.upload, video.publish |
| Token lifetime | 24 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"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
TIKTOK_BUSINESS_CLIENT_ID | Yes | none | Business developer app Client ID. |
TIKTOK_BUSINESS_CLIENT_SECRET | Yes | none | Business developer app Client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds 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_URL | No | https://business-api.tiktok.com/open_api/v1.3 | API origin including the /open_api/v1.3 prefix. The provider appends /business/..., /tt_user/oauth2/..., /discovery/.... |
TIKTOK_BUSINESS_WEB_BASE_URL | No | https://www.tiktok.com | Authorize 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
- Create a TikTok for Business account at https://business.tiktok.com/ (the account whose organic posts you will publish).
- 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.
- 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
- My Apps → Create app / Connect an app.
- Fill in app name, description, website, privacy policy and Terms of Service URLs (HTTPS, public, verified).
- 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.
- Add the six scopes from the next section.
video.publishandvideo.uploadare the posting scopes;video.listanduser.info.*power analytics and the account profile. - 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:
| Rule | Value |
|---|---|
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 port | https://dev.example.com:3000/... is invalid |
| Length 10–512 characters | |
| Up to 10 URLs per app, one active at a time | activating 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_URLwith a port. The Business redirect rules forbid ports, sohttps://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:
- get the app and the six scopes approved;
- connect your own Business Account;
- post with
content_posting_method: "UPLOAD"first (a private draft in the account’s inbox) to validate the pipeline without publishing; - 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_levelyou 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()
| Field | Type | Required | Business behavior |
|---|---|---|---|
title | string (≤ 90) | No | Photos only: post_info.title, truncated to 90. Videos have no title; the caption is post_info.caption. |
privacy_level | enum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY | Yes | Photos 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. |
duet | boolean | Yes | Videos, DIRECT_POST only → disable_duet = !duet. |
stitch | boolean | Yes | Videos, DIRECT_POST only → disable_stitch = !stitch. |
comment | boolean | Yes | Both media types, DIRECT_POST only → disable_comment = !comment. |
autoAddMusic | "yes" / "no" | Yes | Photos, 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_toggle | boolean | Yes | Sent as is_branded_content for both media types. |
brand_organic_toggle | boolean | Yes | Sent as is_brand_organic for both media types. |
video_made_with_ai | boolean | No | Videos, DIRECT_POST only → is_ai_generated. Not supported for photos. |
music | object {id, title?, artist?, image?, audio_volume?, video_volume?} | No | music.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". |
location | object {id, name, address?} | No | location_id + location_name from locationSearch. Photos and videos, DIRECT_POST only. |
content_posting_method | DIRECT_POST / UPLOAD | Yes | UPLOAD 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_levelrequired and one of the four values;duet,stitch,comment,brand_content_toggle,brand_organic_togglerequired booleans;video_made_with_aiboolean when present;autoAddMusicrequired, exactly"yes"/"no";content_posting_methodrequired,DIRECT_POST/UPLOAD;music.idnon-empty; music strings typed; volumes numeric or numeric strings in0..=100;location.idandlocation.namenon-empty;addressa 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_urlis a URL, not a file upload. There is noFILE_UPLOADpath in the Business API: a local filesystem path will not work. Serve the video from a public HTTPS URL.- Thumbnail precedence:
custom_thumbnail_url(frommedia.thumbnail) wins; otherwisethumbnail_offset(frommedia.thumbnail_timestamp, only when non-zero). upload_to_draft: truereplaces all the interaction fields forUPLOAD.- 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_musicis only a fallback: if a random track was found, the flag is false andmusic_sound_infocarries the chosen track.is_draft: truereplaces the interaction fields forUPLOAD.
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’sconvert_to_jpeg = truerewrites 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 != 0or a missingdata.share_id. The provider classifies it itself:RefreshToken/Disconnectbecome provider errors; everything else becomesBadBodywith TikTok’s message. post_pendingstores{ "publishId": <share_id> }.check_post_statuscallsGET /business/publish/status/?business_id=<open_id>&publish_id=<share_id>:SEND_TO_USER_INBOX→Completedwithpost_id = "missing"andhttps://www.tiktok.com/messages?lang=en#<share_id>;PUBLISH_COMPLETE→ numericpost_ids[0]from the raw body (regex, to avoid int64 precision loss) →https://www.tiktok.com/@<username>/video|photo/<id>; if absent → share id andhttps://www.tiktok.com/@<username>;FAILED→BadBodyusingreason+ the envelope;- anything else →
Pending.
resolve_release_idonly handles ids containing_pub_/_inbox_; for non-drafts whose publish id starts withp_pub_and privacy isSELF_ONLYorMUTUAL_FOLLOW_FRIENDS, it returnsUnavailablewithout a call.
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
musicSearch | genre? (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. |
locationSearch | q (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/withitem_id,likes,comments,shares,video_views→ Views, Recent Likes, Recent Comments, Recent Shares (summed).
post_analytics()reuses/business/video/list/with afilters={"video_ids":[post_id]}and returns Views, Likes, Comments, Shares.missing()lists the last 20 videos withitem_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_URLwith an HTTPS port is invalid for Business. The redirect rules forbid ports.https://host:8443/...is rejected when registering the URL. Use 443.redirectmeto.comstill applies for HTTPFRONTEND_URLs. The computed URL ishttps://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_errorsis driven by the body, and each call site checksbody["code"] == 0. Do not rely on HTTP status codes in logs; readcode/message. business_idis theopen_id, verbatim.details()deliberately does not strip dashes (unliketiktok, which removes them) because the value is reused asbusiness_idon every call. Changing the app changes theopen_idand creates a new channel.- Token refresh is JSON, not form-encoded.
POST /tt_user/oauth2/refresh_token/withclient_id,client_secret,grant_type=refresh_token,refresh_token. Refresh tokens last a year but rotate; the response’sopen_idmay be used to keep the identity.expires_inis hard-coded to 23 h;refresh_cronis false, so refresh is reactive on the first expired call. TIKTOK_BUSINESS_BASE_URLmust include/open_api/v1.3. The provider appends paths beginning with/business/...and/tt_user/...; setting it to justhttps://business-api.tiktok.comproduces 404s.TIKTOK_BUSINESS_WEB_BASE_URLis the authorize host, not the API host. Defaulthttps://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 withprivacy_level_option_mismatch(or the Business equivalent) rather than being corrected up front. - No video privacy control.
privacy_levelis 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_lengthis shared with the video-caption limit, so longer photo captions are rejected by Rustiz before TikTok sees them. Thetitleis truncated to 90; the caption is not truncated, just length-checked. autoAddMusic = "yes"overridesmusic. 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/falsebecomes 0, and an unparsable value or an array/object becomes JSONnull(JSON has no NaN). musicSearchis trending-only. No free-text search;genreis the only filter.durationis multiplied by 1000 and may be 0 for a clip with no duration.locationSearchtruncates the query to 100 characters and returns[]for an empty query or a non-token API error.video_url/photo_imagesmust be publicly fetchable. Unlike the legacy provider’sFILE_UPLOAD, the Business API pulls media server-side. Local paths,localhostURLs and private bucket URLs fail withvideo_pull_failed/photo_pull_failedorurl_ownership_unverified.- Draft field names differ by media type. Video uses
upload_to_draft; photo usesis_draft. Both discard the interaction/privacy/music/location fields. thumbnail_offsetis omitted when 0.custom_thumbnail_urlwins 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_COMPLETEcan arrive beforepost_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_idcan 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
[], butRefreshToken/Disconnectpropagate 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_exceededorToo many requests; the provider maps them to aBadBody“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
- TikTok API for Business — authorization and redirect-URL formatting: https://business-api.tiktok.com/portal/docs/marketing-api-authentication/v1.3
- TikTok API for Business — documentation index (authorization, data security review, business account endpoints): https://business-api.tiktok.com/portal/docs/about-the-guide/v1.3
- TikTok API for Business — rate limits: https://business-api.tiktok.com/portal/docs/rate-limits/v1.3
- TikTok API for Business — sandbox accounts (Marketing API, deprecation): https://business-api.tiktok.com/portal/docs/sandbox-accounts/v1.3
- Business developer portal: https://business-api.tiktok.com/portal
- Postiz provider overview (shares the
TikToksettings schema): https://docs.postiz.com/self-host/providers/overview - Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | tumblr |
| Display name | Tumblr |
| Connection mode | oauth (authorization code, refresh token) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published; extra parts are not supported |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Completed |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | true — the OAuth login is not enough; the blog must be selected with pages + channelSetupTool |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 32768 characters |
| Text block limit | 4096 characters per text block (long text is chunked) |
| API | Tumblr 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
TUMBLR_CLIENT_ID | Yes | none | Tumblr app’s OAuth Consumer Key. |
TUMBLR_CLIENT_SECRET | Yes | none | Tumblr app’s OAuth Consumer Secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds 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_URL | No | https://api.tumblr.com/v2 | Overrides 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_URL | No | https://www.tumblr.com | Overrides the web origin used to build /oauth2/authorize. |
ALLOW_PRIVATE_NETWORK | No | false | Only 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
-
Sign in to Tumblr and open https://www.tumblr.com/oauth/apps.
-
Click Register application (or Register a new application).
-
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/tumblrFor example
https://social.example.com/integrations/social/tumblr. Do not add a trailing slash. The path is generated byformat!("{}/integrations/social/tumblr", config.frontend_url())andfrontend_url()strips any trailing slash fromFRONTEND_URL.
-
-
Save the app. Tumblr shows an OAuth Consumer Key and an OAuth Consumer Secret (the secret is behind Show secret key).
-
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" -
Restart Rustiz. Check readiness:
rustiz providers --status | jq '.[] | select(.identifier=="tumblr")'It should report
"configured": trueand 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.
writeis required to create posts.offline_accessis 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:
| Deployment | Redirect URI to register |
|---|---|
Production https://social.example.com | https://social.example.com/integrations/social/tumblr |
Local default FRONTEND_URL | http://localhost:5000/integrations/social/tumblr |
| Postiz docs local example | http://localhost:4200/integrations/social/tumblr |
| Postiz docs Docker example | http://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
pagesfunction 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/triggercommand. A Tumblr channel started withrustiz channels connectcan finish OAuth, but it remainsin_between_steps; complete it with the MCPtriggerTool+channelSetupToolcalls below (or an MCP/API client that exposes them). Until then, publishing is blocked withChannel 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)
-
List the blogs with
triggerTooland thepagesfunction:{ "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" } } }idis the blog name (the*.tumblr.comsubdomain), not the numeric id and not the display title.nameis the display title. -
Choose one and call
channelSetupTool:{ "integrationId": "int_...", "data": { "id": "alice" } }fetch_page_information()looks up thatidinpages(), thenfinish_integration_setup()updates the channel:internalId= blog name;name= blog name (not the display title);picture= avatar URL;profile= blog URL;inBetweenSteps = 0.
-
Verify with
integrationList. Until this step is done,validatePostToolreportsChannel setup is not finishedand scheduled posts fail.
A successful setup returns:
{ "success": true, "name": "alice" }
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
pages | none | Array 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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
Every field is optional; an empty settings object is valid.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | No | minLength: 1, maxLength: 4096. Published as the first content block, {"type":"text","subtype":"heading1","text":...}. |
link | string | No | Optional URL. Published as a {"type":"link","url":...} block after the text. A value without http gets https:// prepended. Empty is ignored. |
sourceUrl | string | No | Optional attribution URL, sent as source_url. A value without http gets https:// prepended. Empty is ignored. |
tags | string | No | maxLength: 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
- If
settingsis not an object, validation returnsOk(())(no fields to check). - For each of
title,link,sourceUrl,tags:- a missing or
nullvalue is skipped; - any present value must be a string.
- a missing or
titlemust be between 1 and 4096 characters inclusive.tagsmust be at most 4096 characters.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:
titleas atext/heading1block (if non-empty).- The message split on runs of two or more newlines, each part trimmed and
chunked to 4096 Unicode code points; each chunk is a
textblock. linkas alinkblock (if non-empty).- 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
jsonmultipart part, plus onemedia-Npart per attachment. Files are streamed from the media store/URL; each retry re-opens the streams. The response must be a JSON object with aresponseobject, otherwise the provider raisesTumblr did not return a valid post response. - The post id is read from
response.id_string, thenresponse.post_id, thenresponse.id. - The release URL is
{integration.profile}/post/{postId}with a trailing slash removed from the profile. After blog setup,profileis the blog URL (for examplehttps://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..
- more than 30 images →
- “Video” is detected only by a case-insensitive
.mp4substring in the path..movand.webmare treated as images even thoughmime_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.thumbnailwhen 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 callpagesand thenchannelSetupToolwith the blog name. A channel in this state is listed but cannot publish. - OAuth
idis the Tumblr user, not the blog.authenticate()setsid/name/usernamefrom the Tumblr user’sname. OnlychannelSetupToolchangesinternalIdto the selected blog name andprofileto the blog URL. Do not use the OAuth result’s id as a blog name. - The blog selector’s
idis the subdomain.pages()returnsid: blog.name;nameis the display title.channelSetupToolexpects{"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_accessis what makes the refresh token available; without it the channel dies after the first token expires.refresh_token()sendsgrant_type=refresh_tokenwith the app credentials and noredirect_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, notinternalIdorprofile, so the selected blog remains the destination. Do not “fix” the channel by reconnecting the wrong blog. - 401/
invalid_grant/invalid_tokenmeans refresh.handle_errors()returns a refresh action for those, plusPlease re-authenticate your Tumblr account.A failed refresh marks the channelrefresh_needed. - Posting is always public.
stateis 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.responseto be an object. A proxy/WAF HTML page becomesTumblr did not return a valid post responseinstead of an empty post id. titleis a heading block, not post metadata. It counts against the 4096-character block limit. Very long titles are rejected by validation, not chunked.tagsis a string. Sending an array (as you would for Dev.to or Hashnode) fails validation withtags must be a string. The string is not normalized; Tumblr expects comma-separated tags.- Only
.mp4is treated as video..mov/.webmare misclassified as images and usually fail atimage_dimensions(). Convert videos to MP4. check_validitydoes 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.profileis empty (for example a channel created outside the normal setup flow), the release URL becomeshttps://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_analyticsandsupports_post_analyticsare all false. Extra post parts are ignored by the single-post provider.
References
- Tumblr API v2 documentation (OAuth 2, NPF, blog posts): https://www.tumblr.com/docs/en/api/v2
- Tumblr app registration: https://www.tumblr.com/oauth/apps
- Tumblr developer resources and rate limits: https://help.tumblr.com/knowledge-base/developer-resources
- Postiz Tumblr self-host guide (scopes and redirect): https://docs.postiz.com/self-host/providers/tumblr
- Postiz provider settings: https://docs.postiz.com/public-api/providers/tumblr
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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
| Property | Value |
|---|---|
| Identifier | twitch |
| Display name | Twitch |
| Connection mode | oauth (authorization code grant) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; each extra entry is a chat reply (reply_parent_message_id) |
| Pending / async publishing | No. supports_pending = false; post() sends and returns Posted/Success |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 5 |
Character limit (max_length) | 500 characters, hard-coded |
| OAuth scopes | user: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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
TWITCH_CLIENT_ID | Yes | — | Client id from the Twitch developer console; also sent as the Client-Id header on every Helix call |
TWITCH_CLIENT_SECRET | Yes | — | Client secret; used for the authorization-code and refresh-token exchanges |
TWITCH_ID_BASE_URL | No | https://id.twitch.tv | OAuth origin (/oauth2/authorize, /oauth2/token). Read through Config::base_url("TWITCH_ID", …). |
TWITCH_BASE_URL | No | https://api.twitch.tv | Helix origin (/helix/users, /helix/chat/...). Read through Config::base_url("TWITCH", …). |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI |
JWT_SECRET | No direct use | empty | Not 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
- Sign in at https://dev.twitch.tv/console with the Twitch account that will own the app.
- Verify the account’s email.
- 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.
- Open Applications → Register Your Application.
2. Register the app
| Field | Value |
|---|---|
| Name | Any unique name, e.g. Rustiz |
| OAuth Redirect URLs | https://social.example.com/integrations/social/twitch — then click Add |
| Category | Choose the closest category (e.g. Chat Bot / Website) |
| Client type | Confidential / server-side (Rustiz stores a client secret) |
| CAPTCHA | Complete 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_URLis trimmed of trailing slashes, so register it without a trailing slash.- There is no
redirectmeto.comwrapper for Twitch. - Local example:
http://localhost:5000/integrations/social/twitch. Twitch acceptshttp://localhostredirects for development; for a public deployment use HTTPS. - The same URI is sent to
/oauth2/authorizeand/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",
];
}
| Scope | Used by | Why |
|---|---|---|
user:write:chat | POST /helix/chat/messages | Send chat messages as the authenticated user |
user:read:chat | Nothing in this provider | EventSub channel.chat.message normally needs it; Rustiz never subscribes. It is an extra scope. |
moderator:manage:announcements | POST /helix/chat/announcements | Send 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()storesexpires_infrom 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/usersagain, so the stored id/name/avatar stay current.- Twitch’s docs require third-party apps that maintain an OAuth session to call
GET /oauth2/validateperiodically. 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_SECRETand reconnect if refreshes start failing.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect twitch --open
- Prints the Twitch consent URL;
--openopens 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. Pollrustiz channels connect-status <id>orrustiz channels list. - No
--external-urland no--fields: Twitch is a normal OAuth provider. PassingcustomFieldsroutes toconnect_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 asbroadcaster_idandsender_id)name= display nameusername= loginaccess_token,refresh_token,expires_inpicture= 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
- If
messageTypeis present and notnull, it must be exactly"message"or"announcement". Any other string, number or object fails withmessageType must be message or announcement. - If
announcementColoris present and notnull, it must be exactly"primary","blue","green","orange"or"purple". Otherwise it fails withannouncementColor must be primary, blue, green, orange or purple. - A
nullvalue is accepted and falls back to the default at publish time. - 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()returns500.post()andcomment()also truncate to 500charsbefore 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_idandsender_idare 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_idto match the user in the access token. - The response is
{ data: [{ message_id, is_sent, drop_reason? }] }. - If
is_sentisfalse, Rustiz maps it toPostStatus::Success(the Rust enum has noerrorstate). 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_idandmoderator_idare 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()(notsend_json) because the retrying client would treat 204 as an error. - Any non-2xx is converted to
bad_bodywith the fixed messageUnknown 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_idtolast_comment_idwhen present, otherwise to the rootpost_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:chatis unused. Rustiz never subscribes to EventSub or reads chat. Twitch may suspend an app that requests scopes it does not need. Remove it frominfo.scopesif 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-exchangeredirect_urithat 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: Beareron Helix, notOAuth. Twitch accepts both for Helix, but the code usesBearerand always sendsClient-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 callsrefresh_token(). If the refresh token is also invalid, the channel is marked for reconnect. - No error classification.
handle_errors()returnsNone; Twitch’s JSON error bodies are passed through the generic layer.send_announcement()replaces every non-2xx withUnknown Error, losing Twitch’smessage. is_sent: falseis hidden. A message dropped by AutoMod, a slow mode, a ban or a follower-only mode is reported asSuccess, not an error. Thedrop_reasonobject is discarded. Check Twitch chat if a post claims success but does not appear.- Announcements have a random post id.
post_idismake_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}(orprovider_identifierifprofileis empty). Twitch chat messages have no stable public permalink. A missingprofileproduceshttps://twitch.tv/twitch, which is wrong. - 2-second sleep on every send.
post()andcomment()sleep 2 seconds before doing anything. This throttles bursts but also adds latency. Combined withmax_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
429withRatelimit-*headers. Chat sends have their own limits; announcements are documented as one per 2 seconds. The generic HTTP layer retries429/5xxup 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_lengthis 500 Unicode scalar values. Twitch’s docs say the message is limited to 500 characters. Rust countschars, 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_idare 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
- Twitch developer console: https://dev.twitch.tv/console/apps
- Registering an app: https://dev.twitch.tv/docs/authentication/register-app/
- OAuth flows and token exchange: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/
- Scopes: https://dev.twitch.tv/docs/authentication/scopes/
- Send Chat Message: https://dev.twitch.tv/docs/api/reference/#send-chat-message
- Send Chat Announcement (one per 2 seconds): https://dev.twitch.tv/docs/api/reference/#send-chat-announcement
- Refreshing access tokens: https://dev.twitch.tv/docs/authentication/refresh-tokens/
- Validating tokens: https://dev.twitch.tv/docs/authentication/validate-tokens/
- Postiz Twitch settings: https://docs.postiz.com/public-api/providers/twitch
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 aswall.post: the call fails witherror_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
| Property | Value |
|---|---|
| Identifier | vk |
| Display name | VK |
| Connection mode | oauth (VK ID authorization code + PKCE S256) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; comments are created with wall.createComment |
| Pending / async publishing | No. supports_pending = false; post() returns PostStatus::Completed |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 5 (the code comments that VK limits are per user token) |
Character limit (max_length) | 2048 |
| VK ID host | https://id.vk.ru (VK_ID_BASE_URL) |
| VK API host | https://api.vk.ru (VK_BASE_URL) |
| API version | 5.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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
VK_ID | Yes | "" | VK ID application id (client_id). An empty value is rejected before the login URL is shown. |
VK_ID_BASE_URL | No | https://id.vk.ru | Overrides 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_URL | No | https://api.vk.ru | Overrides 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_URL | No | http://localhost:5000 | Public origin used to build the redirect URI. Trailing slashes are stripped. |
JWT_SECRET | No | "" | 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.
- Sign in to the VK ID service with VK Business ID.
- Create/verify the business profile.
- Open Мои приложения → Добавить приложение.
- Choose the Web platform.
- Set the base domain (for example
social.example.com). - Set the trusted redirect URL (see below).
- Upload an icon and create the app.
- 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_URL | Redirect URI sent to VK |
|---|---|
https://social.example.com | https://social.example.com/integrations/social/vk |
http://localhost:5000 | https://redirectmeto.com/http://localhost:5000/integrations/social/vk |
http://192.168.1.10:5000 | https://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) orhttps://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:
| Type | Token exchange | Requirement |
|---|---|---|
| Public | From the client/backend without a service key; 15,000 token requests/day per IP | Rustiz’s authenticate() does not send service_token |
| Confidential | Backend only, IP allowlist, service_token mandatory in every token exchange/refresh; 120,000 requests/day per app | Not 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
| Scope | Used for |
|---|---|
vkid.personal_info | Basic profile: first/last name, avatar, user id |
email | Email on oauth2/user_info (not read by Rustiz, but requested) |
wall | wall.post and wall.createComment |
photos | photos.getWallUploadServer / photos.saveWallPhoto |
video | video.save |
status | Requested but not used by this provider |
docs | Requested but not used by this provider |
In the app’s Доступы section, enable these. VK distinguishes:
- Basic access —
vkid.personal_info(always), andemail(optional). - Extended access —
wall,photos,video,status,docs, and others. Extended access requires a verified business profile. Some permissions (notablywall) are described by VK as granted “in exceptional cases” through a support request todevsupport@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.compath is not registrable under VK’s domain rules. - Expect the first publish to be the real test: VK ID login can succeed while
wallaccess 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:
- verified VK Business ID profile;
- extended access approval for
wall/photos/video(and support approval forwallif VK requires it); - 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:
--fieldsuses thecustomFieldspath and base64-encodes JSON as the OAuthcode; VK’sauthenticate()expects a real authorization code plus device id.--external-urlcallsexternal_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:
-
Uploads every media item (see below), concurrently.
-
Builds
attachmentsas a comma-separated list of{type}{user_id}_{media_id}values, e.g.photo123_456,video123_789. -
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.attachmentsis omitted when there is no media. -
Reads
response.post_id, and returnsPostResponse::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.createCommentwithmessage,post_id, and optionalattachments; - reads
response.comment_id; - returns
release_urlfor the original post; - ignores
last_comment_idcompletely, 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_extensionlowercases 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 thoughmime_for()knows some video MIME types.
-
Photos:
GET /method/photos.getWallUploadServer?owner_id={user_id}&access_token=...&v=5.251POSTthe bytes to the returnedupload_urlas multipart fieldphoto;POST /method/photos.saveWallPhoto?access_token=...&v=5.251withphoto,server,hash;- collect
response[0].id.
-
Videos:
GET /method/video.save?access_token=...&v=5.251;POSTthe bytes to the returnedupload_urlas multipart fieldphoto(the code uses the same field name for both types);- use
video.save’sresponse.video_idas the attachment id.
-
Likely video bug: VK’s
video.saveupload server normally expects the file under the multipart fieldvideo_file(the upstream issue reportsvideo_file). The Rust port sendsphoto, so video uploads may not attach. -
Uploads run concurrently via
futures::future::try_join_all; one failure aborts the whole post.max_concurrent_job = 5limits 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()mapsjpg/jpeg,png,gif,webp,mp4,mov,avi,mkv,webm; anything else becomesapplication/octet-stream. -
No alt text, thumbnails or
convert_to_jpegsupport.
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) reportswall.post→error_code: 1051 — "Method is not available for this profile type", while read-only methods such asusers.getwork. Apps created onid.vk.com/id.vk.rucannot fall back to the legacyoauth.vk.comtoken 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 legacyvk1.a.*user token (oauth.vk.comimplicit flow) or a community token, neither of which this provider implements. wallis an exceptional permission. VK’swall.postdocumentation says thewallaccess right is granted in exceptional cases throughdevsupport@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/authmust useContent-Type: application/x-www-form-urlencoded.auth_form()usesreqwest::multipart::Form, which sendsmultipart/form-data; theoauth2/user_infocall 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()readsaccess_tokendirectly. If VK returns an{"error": ...}body with HTTP 200, the empty access token is passed touserinfoand the connection fails with the generic “The platform did not return an access token” message instead of VK’serror_description. - Token exchange omits
state. VK listsstateas a required parameter forgrant_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_tokenis sent on exchange or refresh, and no backend IP allowlist is configured. Use a public app. device_idis 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.comconflicts with VK’s base-domain rule. VK requires the redirect domain to exactly match the app’s base domain. A localhttp://localhost:5000becomes aredirectmeto.comURL, 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_tokenas{refresh}&&&&{device_id}and re-splits it on refresh. If a refresh response omitsrefresh_token,details()stores an empty string and the next refresh fails. - No proactive refresh.
refresh_cronis the defaultfalse; the engine refreshes reactively whencheck_api_errorreturns error 5 or the shared HTTP layer sees a 401. Access tokens last one hour. - Refresh request omits
service_tokentoo. Public apps are fine; confidential apps are not. - VK returns HTTP 200 on API errors.
check_api_error()inspectsall["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_bodywith VK’serror_msg. Because VK uses 200, the shared 401/429/500 classification does not see these errors. HTTP-level failures still go throughhandle_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.postandwall.createCommentare implemented. There is nowall.edit,wall.delete, community posting, scheduled post (publish_date), poll, orfrom_group. - Personal wall only.
post()does not sendowner_id, so it cannot post to a community even if the user is an admin.release_urlassumes the post is on the user’s wall. max_lengthis 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_idowns every medium. The code buildsphoto{user_id}_{id}andvideo{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. usernameis not a handle.details()setsusername = first_name.to_lowercase(), so the channel profile is a lowercased first name, not VK’s screen name.nameis"{first} {last}"(a trailing space if the last name is empty).- The authorize URL has no
prompt,provider,lang_id, orscheme. 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, notOsRng. It is a CSPRNG-backed generator in practice, but the code does not useOsRnglike 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.207and5.300currently respond onapi.vk.ru, but VK changes method behavior between versions. A future removal of5.251requires a code change.
References
- VK ID app creation and settings (Russian): https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/create-application
- VK ID API reference (authorize, token, refresh, userinfo): https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/api-description
- VK ID OAuth 2.1 implementation: https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/realization
wall.post: https://dev.vk.com/ru/method/wall.postwall.createComment: https://dev.vk.com/ru/method/wall.createCommentphotos.getWallUploadServer: https://dev.vk.com/ru/method/photos.getWallUploadServerphotos.saveWallPhoto: https://dev.vk.com/ru/method/photos.saveWallPhotovideo.save: https://dev.vk.com/ru/method/video.save- VK access rights: https://dev.vk.com/ru/reference/access-rights
- VK API errors: https://dev.vk.com/ru/reference/errors
- Postiz issue #1408 (VK ID token /
wall.post1051, video field): https://github.com/gitroomhq/postiz-app/issues/1408 - Postiz VK settings: https://docs.postiz.com/public-api/providers/vk
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
| Property | Value |
|---|---|
| Identifier | whop |
| Display name | Whop |
| Connection mode | oauth (authorization code + PKCE S256; public client) |
| Editor | markdown |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; comments are forum posts with parent_id |
| Pending / async publishing | No. supports_pending is the default false; post() returns PostStatus::Success |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 (the code comments that Whop limits are per token) |
Character limit (max_length) | 50000 |
| API base | https://api.whop.com (WHOP_BASE_URL) |
| Web base | https://whop.com (WHOP_WEB_BASE_URL) |
| OAuth endpoints | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
WHOP_CLIENT_ID | Yes | "" | OAuth app id (app_...). An empty value makes /public/v1/integrations/whop/connect fail before opening a broken login page. |
WHOP_BASE_URL | No | https://api.whop.com | Overrides 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_URL | No | https://whop.com | Overrides the web host used only to build release_url (/experiences/{experience}/{post_id}). For sandbox, https://sandbox.whop.com. |
FRONTEND_URL | No | http://localhost:5000 | Public origin of this Rustiz deployment. The OAuth redirect URI is derived from it. Trailing slashes are stripped. |
JWT_SECRET | No | "" | 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
| Scope | Used for |
|---|---|
openid | OIDC login; required by Whop’s userinfo endpoint |
profile | name, preferred_username, picture |
email | email / email_verified on userinfo (not read by Rustiz, but requested) |
forum:post:create | POST /api/v1/forum_posts (posts and comments) |
forum:read | listing forums (GET /api/v1/forums) and reading forum data |
company:basic:read | listing 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:
| Sandbox | Production | |
|---|---|---|
| Dashboard | sandbox.whop.com/dashboard/developer | whop.com/dashboard/developer |
| API base | https://sandbox-api.whop.com | https://api.whop.com |
| OAuth authorize | https://sandbox-api.whop.com/oauth/authorize | https://api.whop.com/oauth/authorize |
| Web links | https://sandbox.whop.com | https://whop.com |
To test:
- Create the app in the sandbox dashboard and register the same redirect URI there.
- Set
WHOP_CLIENT_IDto the sandbox app id. - Set
WHOP_BASE_URL=https://sandbox-api.whop.comandWHOP_WEB_BASE_URL=https://sandbox.whop.com. - 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:
--fieldsswitches to thecustomFieldspath and base64-encodes the JSON as the OAuthcode; Whop’sauthenticate()expects a real authorization code, so it fails.--external-urlcallsexternal_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:
| Function | Parameters | Returns |
|---|---|---|
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()
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
company | string | Yes | minLength: 1. Company id used to locate the forum. It is not sent in the create-post body. |
experience | string | Yes | minLength: 1. Sent as experience_id to POST /api/v1/forum_posts. This is the actual forum target. |
title | string | No | Optional 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
companymust exist and be a non-empty string.experiencemust exist and be a non-empty string.- If
titleis present and notnull, it must be a string. An empty string is accepted and then omitted at publish time.
Publish behavior
post() uses only the first PostDetails:
- Reads
experiencefrom settings; missing/empty is a hard error. - Uploads every media item (see below) and collects
{ "id": "<file id>" }. - 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
experiencefrom the comment’s settings (required); - sets
parent_idtolast_comment_idwhen supplied, otherwise to the originalpost_id; - reuses the same
POST /api/v1/forum_postsendpoint; - never sends
title; - returns
release_urlpointing 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:
POST /api/v1/fileswith{"filename": "<last path segment>"};PUTthe bytes toupload_url, copyingupload_headersfrom the response;- poll
GET /api/v1/files/{id}every 5 seconds untilupload_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-Lengthitself, then applies Whop’supload_headers; the code intends Whop’s own values (including a length) to win if they are present. - The
PUTdoes not set aContent-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_verifierand noclient_secret; the refresh request sends onlyclient_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 setsrefresh_tokentoSome(...), 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 to3600seconds when Whop omitsexpires_inor sends0.refresh_cron = false, so there is no proactive hourly sweep; the engine refreshes reactively when a call returns 401 or aninvalid_grant/insufficient_scopebody. company_id-scoped tokens cannot be refreshed correctly. If the authorize request usedcompany_id, Whop requires the samecompany_idon refresh.refresh_token()does not send one, so refresh fails and the channel needs reconnecting. Rustiz never sendscompany_iditself, but a manually built authorize URL could.- Reconnect appends
?refresh=to the redirect URI. When a channel is reconnected with?refresh=<internal id>,authenticate()sendsredirect_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.scopesis only used to build the authorize URL. There is nocheck_scopes()call and no post-callback validation. The first API call reveals a missing permission. insufficient_scoperequests 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 forinvalid_grant,insufficient_scope,invalid_requestandnot_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 limitmarkers in a 200/4xx body. - Uploads bypass the retry client. The
PUTto the presigned URL usesexecute()directly, so a transient 429/5xx during the byte upload is not retried. The status polling uses the retrying client. companyis required but unused when publishing. It exists so the composer/tool can resolve the forum. Onlyexperiencereaches the API.- The special
experience_id: "public"target is unsupported. Whop requiresaccount_idwhenexperience_idispublic;forum_body()never sendsaccount_id, so a literalpublicexperience fails. - Only the first post is published.
post()ignoresposts[1..]; additional parts must be comments. - Comments inherit the main post’s experience. If the comment settings
omit
experience, the call fails even thoughpost_idis known. release_urlis 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 incompaniesorexperiencesis swallowed, so an empty list can mean “no access”, “bad token”, or “Whop changed the response shape”. - Tool ids and settings types.
companiesreturnsidas Whop sends it (normally abiz_...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{}forBadBody/RefreshTokenso the authorization code or refresh token is not persisted in the error record.
References
- Whop OAuth guide: https://docs.whop.com/developer/guides/oauth
- Whop permissions: https://docs.whop.com/developer/guides/permissions
- Whop forums guide: https://docs.whop.com/developer/guides/forums
- Create forum post: https://docs.whop.com/api-reference/forum-posts/create-forum-post
- Create file / direct uploads: https://docs.whop.com/api-reference/files/create-file
- Sandbox vs production: https://docs.whop.com/developer/quickstart
- App Store listing requirements: https://docs.whop.com/developer/apps/listing-requirements
- Whop rate limits / troubleshooting: https://docs.whop.com/developer/troubleshooting
- Postiz Whop settings: https://docs.postiz.com/public-api/providers/whop
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 tohttps://<your-site>/wp-json/wp/v2with Basic auth. Use a self-hosted WordPress.org site.
Overview
| Property | Value |
|---|---|
| Identifier | wordpress |
| Display name | WordPress |
| Connection mode | customFields (site domain, username, Application Password) |
| Editor | html |
| Channel kind | social (article content) |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Completed |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 8 (sites are the customer’s own servers) |
Character limit (max_length) | 100000 characters for the post content |
| Inline images | Yes (inline_images = true) |
| API | WordPress 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).
| Variable | Required | Default | Purpose |
|---|---|---|---|
FRONTEND_URL | No (but recommended) | http://localhost:5000 | Used 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_NETWORK | No | false | Global 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_DIRECTORY | No | unset | Global 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.
-
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.
-
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.
-
Create or choose a WordPress user with enough capability:
publish_poststo publish posts immediately;edit_postsfor drafts;upload_filesif a featured image will be uploaded;edit_pages/ the custom post type’s capabilities forpagesor custom types. A least-privilege Author can publish their own posts and upload media; a Contributor can only submitpendingposts.
-
As that user, open Users → Profile (or Users → All Users → Edit) and find Application Passwords.
-
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. -
Verify the credentials before connecting:
curl -s -u 'rustiz:xxxx xxxx xxxx xxxx xxxx xxxx' \ https://blog.example.com/wp-json/wp/v2/users/meRustiz calls exactly this endpoint during
authenticate. A 200 withid,name,avatar_urlsis 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_createfrom WordPress is mapped toThe connect user has insufficient permissions to create posts.- Security plugins/WAFs commonly strip the
Authorizationheader 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
draftis visible only in wp-admin. pendingis visible to editors for review.privateis visible to logged-in users with permission.- Rustiz does not support WordPress’s
futurestatus; scheduling is handled by Rustiz before it calls WordPress. - For a local/staging site on a private address, set
ALLOW_PRIVATE_NETWORK=trueon 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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
domain | text | /^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. |
username | text | /.+/ | WordPress login name, not the display name or email. |
password | password | /.+/ | 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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
postTypes | none | [{"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. |
categoriesList | none | [{"id": <number>, "name": "..."}] | Calls /wp-json/wp/v2/categories?per_page=100. Only the first 100 categories are returned. |
tagsList | none | [{"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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | Yes | minLength: 2; checked. Also used to build the slug. |
main_image | object | No | Featured 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. |
type | string | Yes | REST base of the post type: posts, pages, or a custom type’s rest_base from postTypes. Not the type slug post/page. |
categories | array of integers | No | Category ids. Must be JSON numbers; strings/floats fail validation. |
tags | array of integers | No | Tag ids. Must be JSON numbers. |
status | string enum | No | publish, 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
titlemust exist, be a string and have at least 2 characters.typemust exist and be a string.main_image, if present and notnull:- must be an object;
idmust be a string;pathmust be a string;alt, if present, must be a string;thumbnail, if present and non-empty, must be anhttp(s)URL with a host.
categoriesandtags, if present and notnull, must be arrays whose items are all integers (i64oru64). Floats and numeric strings fail.status, if present and notnull, must be one ofpublish,draft,pending,private.- There is no extension check, size check or
RESTRICT_UPLOAD_DOMAINSgate formain_image.path.
Media requirements and upload behavior
main_imageis uploaded to/wp-json/wp/v2/mediaas raw bytes with:Content-Disposition: attachment; filename="<last path segment>";- a
Content-Typederived 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 isnull, an empty string or numeric0, it is omitted. - The post’s
media/attachmentsarray is ignored. Onlymain_imageis uploaded as the featured image. inline_images = true: Rustiz’s HTML sanitizer keeps<img>tags in the post content and allows theimgtag. Images embedded in the content are the way to put pictures in the body; they are not uploaded by Rustiz.altandthumbnailinmain_imageare 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 emptyslug. 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 localdomainvariable for the/users/merequest:let domain = creds.domain.trim().trim_end_matches('/'). But it storesparams.codeunchanged as the access token, andpost()callsdecode_credentials()again and usescreds.domainraw. A user who pasteshttps://blog.example.com/authenticates successfully and then publishes tohttps://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()returnsid = 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. typeis the REST base, not the WordPress type slug.postTypesreturnsrest_basevalues such aspostsandpages. Sending"type": "post"(as some Postiz examples do) calls/wp-json/wp/v2/post, which is a 404. Useposts.- Categories and tags are ids, not names. Both are numeric arrays. The
provider’s
number_list()also accepts numeric strings at publish time, butvalidate_settings()rejects strings before that, so always send numbers. - Attachments are silently dropped. A post with
attachmentsbut nomain_imagecreates a text-only WordPress post. Put body images in the HTML content instead. - WordPress status
futureis unsupported. WordPress can schedule natively, but the Rustiz enum only allowspublish,draft,pending,private. Let Rustiz schedule the job and sendpublishwhen 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 theIntegration.tokencolumn as a secret. Rotating the Application Password requires reconnecting every WordPress channel. JWT_SECRETis not needed for WordPress. Unlike Listmonk and Lemmy, this provider decodes credentials fromaccess_token, not fromcustom_instance_details. A changedJWT_SECRETdoes not break existing WordPress channels.- Errors from the tools are not classified.
wp_get()usesexecute()+serde_json::from_str; a WordPress error object is valid JSON and is returned as a successfulValue.postTypesthen 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 becomesapplication/octet-stream, which WordPress may reject. - Security plugins are the usual failure mode. Wordfence and similar
plugins can block
/wp-json/, stripAuthorization, or disable Application Passwords viawp_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 withexpires_in: 0. A 401/403 requires reconnecting with a valid Application Password.
References
- Application Passwords (creation, HTTPS, Basic auth, revocation): https://developer.wordpress.org/advanced-administration/security/application-passwords/
- WordPress REST API posts reference: https://developer.wordpress.org/rest-api/reference/posts/
- WordPress REST API authentication overview: https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/
- Postiz provider settings: https://docs.postiz.com/public-api/providers/wordpress
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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 usestwitter-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
| Property | Value |
|---|---|
| Identifier | x |
| Display name | X |
| Connection mode | oauth (OAuth 1.0a 3-legged flow) |
| Editor | html |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; each extra entry is posted as a reply with reply.in_reply_to_tweet_id |
| Pending / async publishing | Yes. supports_pending = true; media is uploaded and polled, then the post is created behind an arm → confirm → publish handshake |
| Analytics | Yes. supports_analytics = true; may be disabled with DISABLE_X_ANALYTICS |
| Post analytics | Yes. supports_post_analytics = true |
| Mentions | Yes. supports_mentions = true; GET /2/users/by/username/{handle} |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 10 |
Character limit (max_length) | 280 normally, 4,000 when the stored Verified additional setting is true, 100,000 for post_type = "article" |
| API base | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
X_API_KEY | Yes | — | Consumer key (“API Key”) from the X app’s Keys and tokens tab. Signs every OAuth 1.0a request. |
X_API_SECRET | Yes | — | Consumer secret (“API Key Secret”) from the same tab. Used as the HMAC-SHA1 signing key together with the token secret. |
X_API_BASE_URL | No | https://api.x.com | Origin 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_URL | No | FRONTEND_URL | Only 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_URL | Yes in practice | http://localhost:5000 | Base of the callback URI when X_URL is unset. Trailing slashes are stripped. |
JWT_SECRET | Yes in practice | empty | Encrypts the pending OAuth state (code_verifier, state) at rest. |
DISABLE_X_ANALYTICS | No | unset | When true, analytics() and post_analytics() return an empty list without calling X. |
STRIP_LINKS_FROM_X_POSTS | No | unset | When true, URLs and bare domains are removed from post and comment text before publishing. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global 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
- Sign in at https://console.x.com (the old
developer.twitter.comportal redirects here). Accept the Developer Agreement and complete the profile. - Create a Project and an App. The app gets an API Key and API Key Secret (the consumer credentials Rustiz needs).
- 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.
- 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
-
POST {api}/oauth/request_tokenwithoauth_callback={callback}andx_auth_access_type=write, signed with the consumer key/secret. -
The temporary token/secret pair is packed into
code_verifieras"{oauth_token}:{oauth_token_secret}";stateis the temporary token. -
The human is sent to:
{api}/oauth/authenticate?oauth_token={token}&force_login=false -
On return,
POST {api}/oauth/access_tokenwithoauth_tokenandoauth_verifier, signed with the temporary token secret. -
GET /2/users/me?user.fields=username,verified,verified_type,profile_image_url,nameverifies 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:
| Plan | Status | Posting allowance / cost |
|---|---|---|
| Legacy Free | Closed to new developers on 6 Feb 2026 | Was 500 posts/month, 100 reads/month; active users were moved to pay-per-use with a $10 voucher |
| Legacy Basic | Retired for new signups (migrated from June 2026) | Was $200/month, 3,000 posts/month per user, 15,000 reads/month |
| Legacy Pro | Retired 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 developers | Prepaid 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 Utility | Application only | Free 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/tweetsis 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_URLcan 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: ...);--openlaunches 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--timeoutseconds (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 withrustiz channels connect-status <connectionId>orrustiz channels list. --external-urlis meaningless for X and should not be passed.--fieldsis 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
customFieldsis for credential providers. Inconnect.rs, anycustomFieldsobject routes toconnect_custom(), which base64-encodes it as the OAuthcode; X then treats it as an authorization code and fails. Never sendcustomFieldstox.externalUrlis 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
}
| Field | Type | Required in practice | Behaviour |
|---|---|---|---|
post_type | "post" / "article" | No; defaults to post | article switches to the Articles endpoints and HTML content |
article_title | string, non-empty | Yes when post_type=article | Sent as the draft title |
article_status | "draft" / "published" | Yes when post_type=article | draft stops after POST /2/articles/draft; published calls the publish endpoint |
article_cover | object | No | {id, path, alt?, thumbnail?}; uploaded separately as a JPEG tweet_image |
who_can_reply_post | enum | Yes for regular posts | Omitted from the request when everyone; sent as reply_settings otherwise |
community | X community URL | No; empty string allowed | Adds share_with_followers: true and community_id (the last path segment) |
made_with_ai | boolean | No; defaults false | Always sent to POST /2/tweets (and to comments) |
paid_partnership | boolean | No; defaults false | Always 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()
settingsmust be a JSON object.post_typemissing 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_postmust be exactly one ofeveryone,following,mentionedUsers,subscribers,verified. A missing value is an error even though the JSON schema does not mark itrequired. - For
article:article_titlemust be a non-empty string andarticle_statusmust bedraftorpublished. article_cover, when non-null:- must be an object;
idmust be a string (an empty string is accepted — it is typed as a string by the DTO);pathmust be non-empty and end in.png,.jpg,.jpeg,.gif,.webpor.mp4;- if
RESTRICT_UPLOAD_DOMAINSis set,pathmust contain that domain; altmust be a string when present;thumbnail, when a non-empty string, must parse ashttp/https.
made_with_aiandpaid_partnershipmust be boolean or string when present (the validator accepts numeric-looking strings;as_boolthen reads"true"case-insensitively).
check_validity()
Only articles are pre-checked:
- If
post_type != "article"→ no checks. - If any media path contains
.mp4→ “X articles only support images”. - If
article_status != "published"andposts.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
| Media | Rule in the Rust port |
|---|---|
| Image | Any 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 GIF | Path contains .gif; every frame is resized and re-encoded, uploaded as tweet_gif |
| Video | Only 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 cover | Uploaded as JPEG tweet_image and sent as cover_media |
| Alt text | Ignored 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
mediawithContent-Type: application/octet-stream. finalizemay returnprocessing_info; Rustiz pollsGET /2/media/upload?command=STATUS&media_id=….
Articles
Articles are a v2 feature added to X in June 2026:
POST /2/articles/draftwith{title, content_state, cover_media?}.content_stateis Draft.js: blocks plus entities. Rustiz converts the HTML subsetp, h1, h2, h3, ul, ol, li, strong, u, a, imginto blocks:h1→header-one,h2/h3→header-two(header-threeis downgraded because X’s draft endpoint returned 503 for it);ul/ol→unordered-list-item/ordered-list-item;strong→boldinline style;a→linkentity;img→atomicblock with animageentity.
- If
article_status = "draft", the returned article id is stored and the release URL ishttps://x.com/i/articles. - If
article_status = "published",POST /2/articles/{id}/publishis called anddata.post_idbecomes the published post id. The release URL useshttps://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
| Method | Kind | Fields | Effect |
|---|---|---|---|
autoRepostPost | @Plug (run_plug) | likesAmount | Reads 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, post | When 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_SECRETare the consumer key and secret, and the stored token isaccess_token:access_token_secret. The provider’s tests verify the signature against X’s documented worked example (Ls93hJiZbQ3akF3HF3x1Bz8/zU4=). X_URLis the callback origin, not the API origin. The Postiz docs sayX_URLoverrides the API base. Inx.rs,callback_url()readsX_URL, whileapi()readsX_API_BASE_URL. SettingX_URLto a self-hosted X-compatible API therefore breaks the callback instead of redirecting API traffic. UseX_URLonly to point the OAuth callback at an HTTPS tunnel whenFRONTEND_URLis a local address.- No token refresh. OAuth 1.0a tokens are long-lived;
authenticate()storesexpires_in = 999_999_999(about 31.7 years) and an emptyrefresh_token.refresh_token()returns an empty access token withexpires_in: 0, so the engine marks the channelrefreshNeededand asks the user to reconnect if X ever rejects the token. There is no automatic repair. - Token secrets are stored in one string.
access_tokenis"{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 flipsattempting=true, confirmed=falseand returns Pending; the next check witnesses it and setsconfirmed=true; only then isPOST /2/tweetssent. 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
confirmedwas 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 withdefault_wait_ms = 1000and a 7-minute max. - Article image waits have no timeout.
upload_bytes()pollsprocessing_infowithmax_wait_ms = None. If X keeps returning acheck_after_secsvalue and never reachessucceeded/failed, that call can loop indefinitely. Video uploads avoid this by returningstill_processingto the durable workflow instead of waiting insideupload_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. Withmax_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
.mp4but cannot process it.validate_settings()allows.mp4inarticle_cover.path, yet the upload path always callsresize_jpeg()on the bytes. An MP4 cover fails at image decode; use an image. article_cover.altand.thumbnailare validated but unused. The draft request only sendscover_media: {media_category, media_id}; neither the alt text nor the thumbnail URL reaches X.article_cover.idis also ignored: the provider uploads the file atarticle_cover.pathitself and uses the returned media id.h3is intentionally flattened toheader-two. The comment in the code says X’s draft endpoint returns 503 forheader-three. If X fixes it, the mapping can be restored.- Polls are not implemented.
PostDetails.pollis ignored entirely. X’sPOST /2/tweetsaccepts apollobject with 2–4 options andduration_minutes5–10080, but Rustiz never sends it. A poll configured in the UI is silently dropped. - Quote posts are not implemented.
quote_tweet_idis never sent; X also requires Enterprise for quote posting on self-serve plans. - Media alt text is not implemented.
MediaContent.altis ignored for X. max_lengthPremium detection is weak. The engine looks for an additional setting{title: "Verified", value: true}captured at connect time fromGET /2/users/me’sverifiedboolean. The code requestsverified_typebut 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_lengthreturns100_000for 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.
communitymust be anhttps://x.com/i/communities/…URL; the provider sendscommunity_idas the last path segment andshare_with_followers: true. A numeric id alone is rejected byvalidate_settings(). reply_settingscharset. X acceptsfollowing,mentionedUsers,subscribers,verified;everyoneis 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 forpublic_metrics. It emits only two points per metric (start and end) withpercentage_change: 5.0hard-coded, so it is a summary, not a real time series. - Post analytics are exact but sparse.
GET /2/tweets/{id}withpublic_metricsreturns 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_POSTSalso affects comments and plugs. The stripper removeshttp(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 Authenticationand"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. A429that does not contain the literal stringToo Many Requestsfalls through to the generic HTTP retry policy. - Rate limits.
POST /2/tweetsis 100/15 min per user and 10,000/24 h per app.POST /2/media/upload/initialize|append|finalizeis 1,875/15 min per user.GET /2/tweetsis 5,000/15 min per user. TheAnalyticslookback 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_VERSIONconstant infacebook.rsis unrelated to X.
References
- X developer console: https://console.x.com
- Getting access / app creation: https://docs.x.com/x-api/getting-started/getting-access
- OAuth 1.0a overview: https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/overview
- Chunked media upload: https://docs.x.com/x-api/media/quickstart/media-upload-chunked
- Create post (including poll schema): https://docs.x.com/x-api/posts/create-post
- Articles introduction: https://docs.x.com/x-api/articles/introduction
- Create draft article: https://docs.x.com/x-api/articles/create-draft-article
- Publish article: https://docs.x.com/x-api/articles/publish-article
- Rate limits: https://docs.x.com/x-api/fundamentals/rate-limits
- Pay-per-use pricing: https://docs.x.com/x-api/getting-started/pricing
- Counting characters: https://docs.x.com/resources/fundamentals/counting-characters
- Postiz X setup: https://docs.postiz.com/providers/x-twitter
- Postiz X settings: https://docs.postiz.com/public-api/providers/x
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
| Property | Value |
|---|---|
| Identifier | youtube |
| Display name | YouTube |
| Connection mode | oauth, then channelSetupTool (channel selection) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | Yes. supports_pending = true; resumable upload session with check_post_status / finalize_post |
| Analytics | Yes. supports_analytics = true; YouTube Analytics API v2, channel level |
| Post analytics | Yes. supports_post_analytics = true; Data API videos.list statistics |
| Mentions | No. supports_mentions = false |
is_between_steps | true — the OAuth channel is a placeholder until a YouTube channel is selected |
convert_to_jpeg | false |
max_concurrent_job | 200 (a semaphore, not a quota; the real limit is YouTube’s daily upload bucket) |
Character limit (max_length) | 5000 characters (the video description) |
| API | YouTube Data API v3, YouTube Analytics API v2, Google OAuth 2.0 |
| OAuth scopes | https://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../youtube, .../youtube.force-ssl, .../youtube.readonly, .../youtube.upload, .../youtubepartner, .../yt-analytics.readonly |
| Token lifetime | Google 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"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
YOUTUBE_CLIENT_ID | Yes | none | Google Cloud OAuth 2.0 Web application client id. |
YOUTUBE_CLIENT_SECRET | Yes | none | Google Cloud OAuth client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI. Trailing / stripped. |
YOUTUBE_BASE_URL | No | https://www.googleapis.com | Data API and oauth2/v2/userinfo origin. |
YOUTUBE_OAUTH_BASE_URL | No | https://oauth2.googleapis.com | Token endpoint (/token) and /tokeninfo. |
YOUTUBE_AUTH_BASE_URL | No | https://accounts.google.com | Authorization endpoint (/o/oauth2/v2/auth). |
YOUTUBE_ANALYTICS_BASE_URL | No | https://youtubeanalytics.googleapis.com | Analytics 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
- Open https://console.cloud.google.com/projectselector2/apis/credentials and accept the terms.
- 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:
| API | Why |
|---|---|
| YouTube Data API v3 | channels.list, videos.insert, videos.list, thumbnails.set |
| YouTube Analytics API | v2/reports channel analytics |
| YouTube Reporting API | Optional; bulk reports. Postiz’s guide enables it, the Rust provider does not call it. |
3. Configure the OAuth consent screen
Open Google Auth Platform → Audience (formerly OAuth consent screen):
- User type: External (unless the project belongs to a Google Workspace org and only internal users need it).
- Fill in app name, support email, developer contact, logo, authorized domains.
- Add the scopes the provider requests (see below). Google classifies the
YouTube scopes as sensitive; the broad
youtubeandyoutubepartnerscopes get extra scrutiny. - 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).
- Keep the publishing status on Testing for development, or Publish app for production.
4. Create the OAuth client
-
Google Auth Platform → Clients → Create client.
-
Application type: Web application.
-
Name it (e.g.
Rustiz). -
Authorized redirect URIs — add exactly:
{FRONTEND_URL}/integrations/social/youtubeExample:
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 unlessFRONTEND_URLitself produced one (it is trimmed). -
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/gmbto the authorized redirect URIs, or by setting separateGOOGLE_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:
| Scope | Use |
|---|---|
userinfo.profile, userinfo.email | GET /oauth2/v2/userinfo (name, picture, account id) |
youtube | Broad channel management; channels.list, metadata |
youtube.force-ssl | Write operations over HTTPS |
youtube.readonly | channels.list for page selection / reconnect |
youtube.upload | videos.insert resumable upload |
youtubepartner | Content-owner features; not needed for ordinary uploads |
yt-analytics.readonly | Analytics 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://localhostredirects (andhttp://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
- Publish the app (Google Auth Platform → Audience → Publish app). New users can then authorize, but they still see the unverified warning until verification completes.
- 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
youtubepartnerand the fullyoutubescope.
- YouTube API Services audit / quota extension is separate. The default
quota is:
- 100
videos.insertcalls per day (its own bucket); - 100
search.listcalls per day (unused by this provider); - 10,000 units/day shared by everything else (
channels.list1,videos.list1,thumbnails.set50). To exceed the default, submit the YouTube API Services — Audit and Quota Extension Form.
- 100
- 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 missingyoutube.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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
pages | none | array of {id, name, picture, username, subscriberCount} | mine=true; silently [] on error. |
Post Settings & Media Validation
settings_schema()
| Field | Type | Required | Behavior |
|---|---|---|---|
title | string (2–100) | Yes | snippet.title. Measured in UTF-16 code units, matching YouTube/JS. No default. |
type | enum public, private, unlisted | Yes | status.privacyStatus. Sent verbatim. |
selfDeclaredMadeForKids | enum yes, no | No | COPPA self-declaration. "yes" → true, anything else → false. Defaults to false when omitted. |
thumbnail | object {id, path, alt?, thumbnail?} | No | Custom 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. |
tags | array of {value, label} | No | label 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.
titleis required and must be a string; its UTF-16 length must be2..=100. Errors:title must be at least 2 characters long,title must be at most 100 characters long.typemust bepublic,privateorunlisted.selfDeclaredMadeForKids, if present, must be exactlyyesorno.thumbnail, if present and non-null, must be an object with stringidand stringpath;alt, if present, must be a string.tags, if present and non-null, must be an array of objects; each needs a stringvalueand a stringlabel.- 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 onlyposts[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)
-
Take the first media path and determine its size:
http(s)URL →HEADwithAccept-Encoding: identity, readContent-Length(must be > 0);- local path →
tokio::fs::metadata.
-
Build the video resource:
{ "snippet": { "title": "...", "description": "...", "tags": ["..."] }, "status": { "privacyStatus": "public", "selfDeclaredMadeForKids": false } } -
POST {YOUTUBE_BASE_URL}/upload/youtube/v3/videos?uploadType=resumable&part=id,snippet,status¬ifySubscribers=truewithContent-Type: application/json; charset=UTF-8,X-Upload-Content-Type: video/mp4andX-Upload-Content-Length: <size>. -
Read the
Locationheader. Missing it →"Could not start the video upload, please try again". -
Return
pendingwith: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>:
| Response | Meaning |
|---|---|
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 Redirect | Incomplete. Range: bytes=0-N → uploadedBytes = N + 1; Ready. |
429 / 5xx | Transient; ProviderError::other so the workflow retries the probe, not the publish. |
401 or a body-classified refresh error | RefreshToken. |
404 / 410 | "The upload session expired before the video was uploaded, please post again". |
| other | BadBody with the classified message. |
| network error | Pending (keep polling). |
Finalize (finalize_post)
- If
videoIdis unknown, probe again. - While
uploadedBytes < videoSize:- stop and return
Pendingif this call has spent 4 minutes (the next call resumes from the session offset); - build an 8 MiB body: HTTP URL →
GETwithRange: bytes=start-end(must be206 Partial Content), local file → seek + read that range; PUT <uploadUri>withContent-Type: video/mp4,Content-LengthandContent-Range: bytes start-end/size;308with aRangeheader → advance and continue;308without a range →Pending(never guess the offset);200/201→ parse{id}and stop;429/5xx→ transient error;401→RefreshToken; other →BadBody.
- stop and return
- If all bytes were sent but no id was confirmed →
Pending. - If a thumbnail was requested, call
POST /upload/youtube/v3/thumbnails/set?videoId=<id>&uploadType=mediawith the streamed image body.- If the thumbnail call fails with
BadBodyandpostDbIdis present, returnProviderError::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.
- If the thumbnail call fails with
Completedwithwatch?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.pathwins; otherwisemedia.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_idis a Google account id until setup finishes.AuthTokenDetails.idisuserinfo.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 runchannelSetupToolwith{"id": "UC..."}.refresh_token()returns the Google account, not the channel. It callsuserinfoand returns the account id/name/picture. The engine only stores the access token and expiry, so the channel’sinternal_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 missingyoutube.readonlylooks 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_scopesthen refuses the connection. There is no partial-scope mode. youtubepartneris a partner-only scope. Requesting it (and the fullyoutubescope) 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/RefreshTokenis the classic symptom. Publish the app and complete verification for production use. - No
categoryIdis sent. YouTube usually applies a default category, but if an upload is rejected withinvalidCategoryId, this is the missing field. The same applies todefaultLanguage/localized metadata, which the port does not support. notifySubscribers=trueis hard-coded. Every upload notifies subscribers; there is no setting to suppress it.prompt=consentforces 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. tokeninfois called with POST + bearer, not a query parameter. Google accepts both; the port uses POST. If you proxy Google OAuth, allowPOST /tokeninfo.- Channel identity is re-checked on reconnect.
reconnect()listschannels?part=id&mine=trueand requires the requested channel id to be present; otherwise"Channel not found". It then refetches the channel metadata. This path only runs whenroot_internal_iddiffers frominternal_id, which normal OAuth channels do not set. - Upload sessions are ephemeral. A session that returns
404/410is gone; the provider fails the post with “post again” rather than retrying a dead session. A308without aRangeheader also returnsPendinginstead 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_streamrequires206 Partial Content. A storage backend/CDN that ignoresRange(returns200with the whole file) fails with"The media storage did not return the requested byte range". - Thumbnail failures preserve the published video.
PublishedWithErrorcarries 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 missingyt-analytics.readonly).post_analytics()surfacesRefreshTokenbut 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,subscribersLostwithdimensions=day, sorted by day. The emitted series are Estimated Minutes Watched, Average View Duration, Average View Percentage, Subscribers Gained, Subscribers Lost and Likes —viewsis queried but never emitted (an upstream omission). Post analytics emits Views, Likes, Comments, Favorites fromvideos.list. - Quota is the real concurrency limit.
max_concurrent_job = 200only sizes a semaphore. The defaultvideos.insertbucket is 100/day, and eachthumbnails.setcosts 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_rapthas 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 thoughyoutube.force-sslwould permit them.
References
- Registering an application / OAuth credentials: https://developers.google.com/youtube/registering_an_application
- Server-side web OAuth flow (offline access,
prompt=consent, redirect rules): https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps - Uploading a video (resumable protocol,
videos.insert): https://developers.google.com/youtube/v3/guides/uploading_a_video - Quota cost table (
videos.insertbucket,thumbnails.set50 units): https://developers.google.com/youtube/v3/determine_quota_cost - API compliance audit / quota extension: https://developers.google.com/youtube/v3/guides/quota_and_compliance_audits
- OAuth app audience and Testing-mode limits (100 users, 7-day expiry): https://support.google.com/cloud/answer/15549945
- Managing OAuth clients and redirect URI rules: https://support.google.com/cloud/answer/15549257
- Postiz YouTube guide: https://docs.postiz.com/self-host/providers/youtube
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview
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.
| Binary | Ready | RAM idle | RAM after load | Throughput |
|---|---|---|---|---|
| 16.9 MB | 117 ms | 19.0 MB | 24.4 MB | about 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.
| Command | Purpose |
|---|---|
rustiz init | First-time setup |
rustiz serve | HTTP API, MCP and the scheduler (--no-worker for HTTP only) |
rustiz worker | The scheduler alone |
rustiz mcp | MCP over stdio for a local agent, with the scheduler and login callback in-process |
rustiz posts|channels|media|analytics|notifications|find-slot | CLI for agents |
rustiz org create|list|rotate-key | Organizations 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
| Route | Limit per hour | Setting |
|---|---|---|
POST on /public/v1/posts | 90 per organization | API_LIMIT |
/mcp and other /public/v1 routes | 10,000 per organization | REQUEST_LIMIT |
POST /oauth/authorize, /oauth/register | 30 per client address | fixed |
POST /oauth/token | 120 per client address | fixed |
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
schedulePostToolcall within 10 minutes, or one with the sameidempotencyKeywithin 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 pollpostStatusTool. - 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 withconnectionId. Channels markedinBetweenStepsare finished withtriggerToolandchannelSetupTool. - Live updates:
subscriptions/listenonrustiz://posts/<id>orrustiz://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 separaterustiz 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
| Postiz | This 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:
imagereplacessharp, and a hand-built PDF replacespdfkit, 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
| Workflow | Status |
|---|---|
| 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
| Area | Status |
|---|---|
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 initgenerates it. - Only creating posts over REST uses
API_LIMIT, as upstream./mcpand other API routes get a separateREQUEST_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.previewUrlis gone fromschedulePostToolresults, 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.
| Metric | Result | How |
|---|---|---|
| Binary size | 16.9 MiB (17,721,744 bytes) | ls -l target/release/rustiz after cargo build --release --locked -p rustiz |
| Startup | 97–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, idle | 18.8–19.5 MB RSS | ps -o rss 2 s after startup |
| RAM, after load | 25.8 MB RSS | ps -o rss right after the throughput runs below |
| Throughput | ~53,500 req/s (32 clients), ~58,700 req/s (64 clients), 0 failures | ab -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:
| Claim | Basis |
|---|---|
| 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+ RAM | Estimate for a stack that includes Elasticsearch, two Postgres servers, Temporal and a Node.js app. Not measured. |
| Startup in tens of seconds | Estimate. Not measured. |
If you have measured Postiz numbers, please open an issue and we’ll replace these estimates.