# OpenPost Documentation Full Corpus > This documentation-only file is an OpenPost convenience artifact for reading the selected public documentation as one bounded corpus. > > It is not part of the llms.txt v2 proposal. Use llms.txt for the discovery index and each page's canonical URL for current source provenance. ## User guide ### Concepts Source: [https://docs.openpo.st/guide/concepts.md](https://docs.openpo.st/guide/concepts.md) Use this page when you need the product terms used by the app and documentation. These terms appear in the app and docs. These definitions do not override provider rules, account permissions, or plan limits. #### Workspace A workspace keeps a brand or client's accounts, posts, media, prompts, schedule, and members together. Each member has a role. A pending invite uses a team seat until someone accepts it, you cancel it, or it expires. #### Social account A connected social account, such as one X account or one Mastodon profile. #### Publication A publication is OpenPost's saved record for one post, thread, Story, short video, or video. It keeps the shared content, selected accounts, account versions, media, schedule, and status together. The app usually calls this a **post**. The API and CLI use **publication**. #### Draft An editable publication that has not gone live. You can create a draft from the web app, CLI, API, or MCP. #### Thread A set of posts published in order. OpenPost sends each reply in the form required by the social network. #### Account version The text, media, format, and settings for one selected account. It can use the shared content or override it. The API and CLI call this a **rendition**. #### Media Files saved in the media library and attached to posts. Threads, Facebook, Instagram, and some TikTok publishing flows need a public media link set through `OPENPOST_MEDIA_URL`. #### Job Work that OpenPost saves in its database and runs in the background. Saved jobs let scheduled posts survive a server restart. #### Provider A social network that OpenPost can connect to. Technical docs may also use **provider** for the code that handles a network. #### Callback URL The address a social network returns to after sign-in. It must match the address set in that network's developer portal. #### Public media URL The public base address for uploaded media. Threads, Facebook, Instagram, and some TikTok flows use it to fetch a file. ### Quickstart Source: [https://docs.openpo.st/guide/quickstart.md](https://docs.openpo.st/guide/quickstart.md) This is the fastest path to a working OpenPost instance. If you prefer not to use Docker, jump to [Single Binary](https://docs.openpo.st/installation/binary.md). The published image and maintained Dockerfile support `linux/amd64` only. Other host architectures need amd64 emulation. A native image requires a downstream Dockerfile/source change and complete runtime validation. #### 1. Create `docker-compose.yml` ```yaml services: openpost: image: ghcr.io/getopenpost/openpost:latest platform: linux/amd64 container_name: openpost restart: unless-stopped env_file: - .env ports: - "8080:8080" volumes: - openpost_data:/data environment: - OPENPOST_PORT=8080 - OPENPOST_DATABASE_PATH=/data/db/openpost.db - OPENPOST_MEDIA_PATH=/data/media volumes: openpost_data: ``` #### 2. Create `.env` From the repository root, copy the safe deployment example: ```bash cp .env.example .env ``` Set fresh values for the two required secrets, then set `OPENPOST_APP_URL`, `OPENPOST_PUBLIC_URL`, and `OPENPOST_MEDIA_URL` for the URL where users will actually reach the app. For a local evaluation, `http://localhost:8080` is fine. Start with Bluesky if you want the easiest first platform: it does not need a server-side OAuth app. Add other platform settings later. #### 3. Generate secrets ```bash openssl rand -base64 32 openssl rand -base64 32 ``` Use one generated value for the JWT secret and the other for the encryption key. > **Warning** > > Do not use placeholder secrets in production. #### 4. Start OpenPost ```bash docker compose up -d ``` #### 5. Open the app Visit `http://localhost:8080`. #### 6. Finish first-run setup 1. Create your OpenPost account. The first account on the instance becomes the instance admin automatically. 2. Create or select a workspace. 3. Connect your first social account. 4. Create a post, choose a time a few minutes ahead, then choose **Schedule**. 5. Confirm the post appears in Activity as scheduled, then wait for it to go live. #### 7. Recommended first platform Start with **Bluesky** if you want the fastest first check: 1. In Bluesky, open Settings and create an app password. 2. In OpenPost, go to Accounts and connect Bluesky with your handle and app password. 3. Publish or schedule a short text post first. #### What success looks like - You see the registration or login screen on first load. - After signing in, OpenPost opens the workspace-aware app shell. - The Social accounts screen shows your connected account. - The editor lets you pick that account. - The Activity screen shows the scheduled post, then later shows it as published. #### HTTPS note `http://localhost:8080` is fine for a local test. Before you set up OAuth for real accounts, put OpenPost behind HTTPS with a real domain and update `OPENPOST_APP_URL`, `OPENPOST_PUBLIC_URL`, and `OPENPOST_MEDIA_URL`. X, LinkedIn, and Threads need exact callback addresses. Passkeys need HTTPS. Threads, Facebook, Instagram, and some TikTok flows need public media links. If you want to close self-service signups after setup, set `OPENPOST_DISABLE_REGISTRATIONS=true` and restart OpenPost. The first account is still allowed on a brand-new instance even when that flag is enabled. #### Next steps - [Docker Compose details](https://docs.openpo.st/installation/docker-compose.md) - [Single binary install](https://docs.openpo.st/installation/binary.md) - [Environment variables](https://docs.openpo.st/configuration/environment-variables.md) - [Platform setup](https://docs.openpo.st/providers/index.md) ### What Is OpenPost? Source: [https://docs.openpo.st/guide/what-is-openpost.md](https://docs.openpo.st/guide/what-is-openpost.md) OpenPost helps solo founders turn launches, product updates, lessons, and ideas into content for supported destinations. Start with one shared draft, then change the text, media, format, and settings for each connected account. OpenPost includes the complete publishing workflow. #### Who is this for? OpenPost is for people who are building a company and also have to create its audience. - **Solo founders** that need a repeatable content system without hiring a content team - **Creators and small brands** that publish to several accounts - **Teams and agencies** that need separate workspaces for brands or clients - **Developers** that use the API, CLI, or MCP #### How it works 1. Choose Post, Thread, Story, Short video, or Video. 2. Write the shared text and add media. 3. Select the social accounts. 4. Tailor and preview the version for each account. 5. Publish now or choose a time. 6. Check the result, errors, and retry options in the app. OpenPost also has a media library, a still-image editor called OpenPost Image Editor, posting schedules, analytics, comments and replies, personal alerts, and inboxes for supported accounts. If an AI tool helps, give it an OpenPost token instead of your social account keys. `mcp:read` is read-only. `mcp:full` can create, change, schedule, publish, reply, or moderate. Review the result before you allow a change. See [Agent-Assisted Publishing](https://docs.openpo.st/usage/agent-assisted-publishing.md). #### Plans and deployment See the canonical Hosted service pricing for current plan terms. An active or trialing Hosted service plan is required to connect social accounts, upload media, schedule, or publish on that service. Teams that need to operate their own infrastructure can also run the AGPL product on their server. The default setup uses one Go binary or container, SQLite, and local media storage. The operator manages TLS, backups, updates, social apps, and secrets. #### Implemented provider adapters The list below describes implemented product adapters. Hosted service certification is tracked separately in the [provider launch matrix](https://docs.openpo.st/operations/provider-launch-matrix.md). - **Publishing adapters:** X, Mastodon, Bluesky, LinkedIn profiles and Organization Pages, Threads, Facebook Pages, Instagram Business and Creator accounts, TikTok, YouTube, and Discord webhooks - **Content types:** posts, threads, Stories, short videos, and videos, based on each platform - **Results:** account and post analytics when the platform grants access - **Replies:** comments, replies, and moderation for supported platforms - **Inbox:** opt-in message collection for X, Bluesky, Facebook Pages, Instagram, and Mastodon - **Workspaces:** separate accounts, media, schedules, members, and tool access - **Tools:** web app, HTTP API, CLI, and MCP Implemented adapters do not prove current Hosted service availability. App review, account access, API limits, public media links, or a failed live test can still block an account or format. See [Supported Platforms & Limitations](https://docs.openpo.st/providers/index.md). #### What OpenPost is not OpenPost does not include social listening, ad management, a CRM, or large-company benchmarks. Each social network gives different access to media, analytics, comments, and messages. **Current limitations:** - **Video differs by platform.** Formats, lengths, file sizes, and app review rules vary. - **Features differ by platform.** OpenPost only shows actions that the connected account can use. - **Analytics keep each metric distinct.** Views, impressions, and reach do not mean the same thing. - **There is no separate server approval step for every tool action.** A person should review AI-made work before allowing it. - **Social networks can change their APIs.** Their limits and review rules can affect publishing. #### What it does not require - Redis for scheduled jobs - PostgreSQL for a small self-hosted setup - A Hosted service plan when you operate a self-hosted deployment - Several app services for the default install ### OpenPost Source: [https://docs.openpo.st/index.md](https://docs.openpo.st/index.md) Turn what you are building into destination-ready content. Create, adapt, schedule, and track work from one workspace. - [Read the user guide](https://docs.openpo.st/usage/index.md) #### Use OpenPost Connect accounts, create destination versions, schedule publications, and review results. #### Connect a provider See setup steps, supported formats, account requirements, and current limitations. #### Automate with CLI or MCP Use the same workspace and authorization boundaries from scripts and AI tools. #### Self-host OpenPost Install, configure, back up, upgrade, and operate your own deployment. #### Build with the API Read the HTTP contract, authentication model, and development guides. #### Learn the core concepts Understand Publications, Renditions, destinations, schedules, and publishing state. The all-in-one content team for solo founders, from first draft to every destination. > **Hosted service plans** > > See the canonical Hosted service pricing for current plans, trial terms, and limits. Self-hosting is a separate operator-run deployment. Provider access and available formats still depend on account type, permissions, review, and current provider support. ![OpenPost main dashboard](https://docs.openpo.st/assets/screenshots/main-dark.png) #### Installation For the authoritative Docker Compose example and deployment steps, see [Docker Compose](https://docs.openpo.st/installation/docker-compose.md). > **Tip** > > New to OpenPost? Read the [user guide](https://docs.openpo.st/usage/index.md), watch the product demo, or follow the [self-hosting quickstart](https://docs.openpo.st/guide/quickstart.md). #### Choose the right docs - **[User docs](https://docs.openpo.st/usage/index.md)** cover the web app, CLI, and MCP. Learn how to connect accounts, write account versions, schedule posts, track results, use OpenPost Image Editor, and manage replies. - **[Self-hosting docs](https://docs.openpo.st/self-hosting/index.md)** cover setup, social app keys, storage, backups, upgrades, and fixes. - **[Developer docs](https://docs.openpo.st/development/index.md)** cover the code, API, tests, platform links, billing, MCP, and releases. #### More ways to use OpenPost - Use the [CLI](https://docs.openpo.st/cli/index.md) from a terminal, scheduled script, or CI job. - Connect an AI tool through [MCP](https://docs.openpo.st/mcp/index.md) to read, draft, or schedule with the access you grant. - Test an AI-assisted campaign with the public OpenPost Launch Kit. - Install the [Android app](https://docs.openpo.st/installation/android.md) from the APK shipped with each GitHub release. ### Account Security Source: [https://docs.openpo.st/usage/account-security.md](https://docs.openpo.st/usage/account-security.md) Open **Settings → Personal → Security** to manage your password, authenticator app, recovery codes, passkeys, linked sign-in identities, and active browser sessions. These settings belong to your login and apply in every workspace. #### Sign-in sessions Web sign-in creates a persistent, HTTP-only session cookie that expires after seven days. OpenPost does not show a cosmetic “Remember me” option because there is currently one server-enforced lifetime. Sign out when you finish on a shared device. You can review and revoke active browser sessions from **Settings → Personal → Security**; revocation invalidates that session before its normal expiry. #### Change the sign-in email Open **Settings → Personal → Security → Change sign-in email**, enter the new address, and confirm your current password, passkey, or linked sign-in identity. OpenPost sends a six-digit code to the new address and a notice to the current address. The current address remains the active sign-in identity until the code is confirmed. The code expires after 15 minutes and repeated attempts or resends are limited. OpenPost uses a generic conflict message when an address cannot be used, but the different success and conflict status codes can still reveal whether an address is available to an authenticated user. On confirmation, OpenPost changes the sign-in email, invalidates unused password-reset links, and revokes other browser sessions. It attempts to send completion notices to both addresses. Those post-confirmation notices are currently best effort: delivery failures are logged, but the application does not yet queue a durable retry. Changing the sign-in identity does not silently change billing, legal, workspace-invitation, or notification contacts. Update those separately where the product exposes them. #### Linked sign-in identities and logout Linked Google or organization identities are login methods. They are separate from the social publishing accounts under **Settings → Workspace → Social accounts**. The Security page shows the provider, linked name and address, link date, and last use. Disconnect requires recent reauthentication, and OpenPost blocks removal of the final usable password, passkey, or linked identity. OpenPost always revokes its own session when an authenticated provider logout is requested. If the provider advertises an OIDC end-session endpoint, OpenPost also returns that provider logout URL; otherwise logout ends at OpenPost. Configured providers can also send standards-based back-channel logout to the provider-specific URL shown in SSO settings. Support depends on the provider exposing and correctly configuring those endpoints; OpenPost does not claim that every provider performs remote logout. #### Required organization SSO and app tokens When an organization requires SSO, its administrator can either deny API, CLI, and MCP tokens or limit each token to one workspace. A token for an SSO-protected workspace must inherit a current assurance from an approved identity provider. It cannot access another workspace or organization-level resources, and it stops working when that assurance exceeds the organization's maximum age. Migration 084 changes the retired whole-organization token setting to workspace-scoped access. It does not guess a workspace for an existing unbound token, so that token can no longer access required-SSO resources. Revoke it and issue one assured token per required workspace, or choose the deny policy. #### Set up an authenticator app 1. Choose **Authenticator app** as your two-factor sign-in method. 2. Confirm your current password, passkey, or linked sign-in identity. 3. Keep the OpenPost page open, add OpenPost in your authenticator app, and scan the QR code. If you cannot scan it, choose manual entry in the app and use **Copy setup key**. You can switch between the apps without losing the setup. 4. Return to OpenPost and enter the current six-digit code from the app. 5. Copy or download the recovery codes and store them somewhere safe and separate from the authenticator. 6. Confirm that the recovery codes are saved. OpenPost then enables the authenticator app. The setup key gives the same access as the QR code. Keep it private. If browser clipboard access is unavailable, select the read-only key field and copy it manually. The authenticator app is not enabled after step 4. Setup finishes only after you confirm that the recovery codes are saved. If you close or discard the one-time code list first, restart setup to get a new authenticator secret and recovery-code set. OpenPost displays each recovery-code set once and stores only hashes. Each code can finish one sign-in, after which it cannot be used again. Keep the codes private and separate from the device that holds your authenticator app. #### Sign in with a recovery code After entering your email and password, choose **Use a recovery code** on the verification screen. Enter one unused code exactly as saved. Spaces, letter case, and the displayed hyphens do not affect verification. OpenPost removes the code from the available sign-in set after a successful use. If no unused codes remain, the recovery-code option is no longer offered. #### Check or replace recovery codes The Security page does not show the remaining count until you confirm your current password, passkey, or linked sign-in identity. Choose **Check remaining codes** to see the number of unused codes. Choose **Generate new recovery codes** when the saved set may be exposed, lost, or nearly exhausted. The replacement set is displayed once. Your current codes remain valid while you copy or download the replacement. After you confirm that the new set is saved, OpenPost activates it and revokes every code in the old set. If you discard the replacement before confirming it, the current set stays active. Start replacement again to receive another new set. #### Disable the authenticator app Disabling the authenticator app requires the same recent identity check used for recovery-code management. Disabling it also revokes every recovery code and cancels unfinished authenticator or replacement setup. Resetting a password does not bypass an enabled second factor. After a password reset, use the authenticator app, a saved recovery code, or a passkey to finish signing in. #### Store codes safely - Prefer a password manager, encrypted vault, or offline copy that you can reach without the authenticator device. - Do not store the only copy in the same phone, browser profile, or unencrypted notes app as the authenticator. - Replace the set immediately if someone else may have seen it. - Check the remaining count after using a code so you can replace the set before it is empty. ### Accounts Source: [https://docs.openpo.st/usage/accounts.md](https://docs.openpo.st/usage/accounts.md) Use this page when you want to connect or manage social accounts in a Workspace. Connected accounts are the social accounts inside a workspace. #### Common flow 1. Open **Settings → Workspace → Social accounts**. 2. Choose a platform. 3. Sign in to that platform and approve access. 4. Return to OpenPost. A successful first connection opens a fresh composer with the new destination selected. If you cancel authorization or the provider cannot finish the connection, OpenPost returns to account management with a retry message. Refreshing or signing in again does not create a separate setup state; the Workspace setup guide reads current subscription, destination, first-composition, and Publication data. **Settings → Workspace → Social accounts** is the account-management screen. OAuth cancellation and error feedback appears there once, then OpenPost removes it from the URL so refresh and browser history do not repeat stale feedback. #### Optional features per connected account Direct messages, Comments and replies, Analytics, and Grow are optional and per connected account. Each feature starts off for a newly connected account. Manage these choices in the Account details drawer under **Settings → Workspace → Social accounts**. The drawer keeps feature choices together and collapses the developer shortcut until you need it. Direct messages and Comments and replies appear together under Inbox, but OpenPost saves and enforces them as separate choices. Enabling a feature queues an initial durable refresh for that account. Enabling Analytics also permits OpenPost to discover eligible provider content, including content published outside OpenPost, and store normalized metrics plus bounded title and text for evidence and user-reviewed repurposing. It does not import remote media or create Publications from discovered content. Disabling a feature stops future provider reads and writes for that account without deleting previously collected messages, replies, metrics, discovered content, or recommendations and without revoking provider authorization. Use provider settings or account removal to revoke provider authorization. Whether a feature is available depends on three distinct facts: provider support for that account, required provider scopes, and plan access. An unsupported feature is omitted. A missing scope tells you to reconnect with additional permission. A plan restriction stays a billing matter and does not imply that reconnecting will fix it. Grow shows recommendations for eligible accounts and never follows any account automatically. Each follow remains an explicit action and requires Grow to stay enabled. Existing accounts keep their current behavior after upgrade. Previous Inbox opt-ins become Direct messages choices, current Analytics and Engagement behavior remains enabled, Grow becomes enabled only where OpenPost already has stored Grow sync state for that account, and other accounts receive explicit off choices so the prompt does not appear on routine reauthorization. #### Notes - OpenPost stores provider tokens, app passwords, and webhook credentials encrypted at rest. One saved authorization can serve several destinations, such as a LinkedIn member and the organizations that member manages. - When destinations share an authorization, **Disconnect this destination** removes only the selected destination. The other destinations and their saved credentials remain active. - **Remove saved authorization** deletes OpenPost's encrypted credentials and disconnects every destination that uses them. When only one destination remains, **Remove connection** is the only removal action so a live credential cannot be left behind without an active destination. - Removing a saved authorization does not disable the token, app password, or webhook at the provider. Use the provider's connected-app, app-password, or webhook settings when provider-side access must also be revoked. - These account actions do not delete the operator's provider app configuration from environment variables, `OPENPOST_PROVIDER_APPS`, or the provider app registry. - Each platform has its own callback and permission needs. - OAuth return links contain only the generic status, opaque selection reference, and OpenPost Workspace or destination identifiers needed for the next screen. They never include provider tokens, credentials, or secret-bearing provider data. - Authenticated clients can call `GET /api/v1/accounts/providers` to discover which provider apps are configured before showing connect actions. - Mastodon can use either preconfigured instances or the custom instance field on the Accounts screen. Custom instances must be public HTTPS servers. ### Agent-Assisted, Human-Reviewed Publishing Source: [https://docs.openpo.st/usage/agent-assisted-publishing.md](https://docs.openpo.st/usage/agent-assisted-publishing.md) An AI tool can prepare posts through MCP without seeing your social account keys. OpenPost keeps those keys, workspace data, checks, and post status in the app. A person reviews the work before allowing a change. This works well for releases, product updates, open-source news, and repeat campaigns that need different text for each account. #### What the agent can do With an `mcp:read` token limited to one workspace, a tool can read the workspace, connected accounts, platform setup, media, drafts, schedule, post status, and results. It cannot make changes. With `mcp:full`, an authorized agent can also: - create or update a shared draft; - prepare account versions and choose media; - check a post and suggest an open time; - schedule or publish through `execute_operation` after the client receives approval; - read post status, events, published results, and errors. The tool does not receive social network access or refresh tokens. OpenPost uses them when it calls the network. > **MCP authority** > > Use `mcp:read` for read-only work. An `mcp:full` token can make changes when the MCP client allows them. Limit it to one workspace, require approval for `execute_operation`, and remove it when the tool no longer needs access. #### Steps | Stage | Agent task | Human task | | --------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Inspect | Read the workspace, platform list, accounts, media, drafts, and setup state. | Confirm the workspace and accounts. | | Prepare | Draft one shared message and an account version for each account. | Check facts, tone, links, media rights, and alt text. | | Check | Check the post and suggest times. | Choose the accounts, format, and time. | | Approve | Show the exact change and wait. | Review it in the web app, then approve the exact `execute_operation` call. | | Follow up | Read post status and events. | Fix errors and save the real post links. | A person should review the work. OpenPost does not add a separate server approval step before every allowed change. #### 1. Limit access Connect through the remote MCP endpoint or the local `openpost-mcp` proxy as shown in [Agent-Assisted Publishing With MCP](https://docs.openpo.st/mcp/index.md). Start with an `mcp:read` token limited to the current workspace. Grant `mcp:full` only when the tool must create or change data. Do not paste social account keys into the prompt. The MCP client needs an OpenPost token, not X, Meta, LinkedIn, Bluesky, Mastodon, TikTok, or Google keys. #### 2. Start with read-only inspection Ask the tool to use `search_operations` to find the current inputs. It should use `query_operation` for reads and report: - the workspace ID and intended accounts; - each platform's setup result; - relevant media and its accessibility text; - existing drafts that may overlap; - candidate posting slots; - any platform or format that still needs a live test. App settings, a working OAuth start, or finished code do not prove that a real account and format can publish today. Use the [Launch Verification Matrix](https://docs.openpo.st/operations/provider-launch-matrix.md). #### 3. Prepare a shared post and account versions Keep the shared message factual and broad enough for each platform. Then tailor it by account: - shorten and front-load the point for X; - add context and a clear professional takeaway for LinkedIn; - use a compact thread when Bluesky needs more room; - include self-hosting and federation-relevant detail for Mastodon; - use a more casual account version for Threads after that exact live test works. Do not paste the same text into every account. Keep the facts and call to action, but change the structure, length, links, and media when needed. #### 4. Review in OpenPost Before approval, inspect every account in the composer: 1. Confirm the account and platform. 2. Read the account version on its own. 3. Check character, thread, media-count, and format limits. 4. Verify links, mentions, hashtags, titles, descriptions, and alt text. 5. Remove platforms that are not set up or tested for that format. 6. Confirm the workspace timezone and scheduled time. 7. Save edits before you approve the change. #### 5. Approve the exact change The tool should show the operation name, workspace, account IDs, media IDs, format, and time before it calls `execute_operation`. Approve that exact change, not all future posts from the tool. #### 6. Record outcomes, not intentions A saved draft only proves that OpenPost saved it. A scheduled state only proves that OpenPost accepted the time. Neither proves that the social network published it. After OpenPost runs the post, save the result for each account: - published URL and platform post ID; - failure message and retry state; - verification date, account, and format; - any manual edit made after the agent's draft. Use the OpenPost Launch Kit for a reusable brief, sample prompt, five account versions, a review list, and a result template. #### Hosted service access See the canonical Hosted service pricing for current plan terms. An active or trialing plan is required to connect accounts, upload media, schedule, or publish. ### Analytics Source: [https://docs.openpo.st/usage/analytics.md](https://docs.openpo.st/usage/analytics.md) Use this page when reviewing saved whole-account content results. Analytics is an optional feature per connected account. It starts off for a newly connected account. Enable it after connection or in Account details to let OpenPost collect account metrics and discover eligible provider content, including content published outside OpenPost. OpenPost stores normalized metrics plus bounded title and text for eligible discovered items so you can inspect evidence and prepare a repurpose draft. It does not import remote media or turn discovered items into Publications. Disabling Analytics stops future account, Rendition, and account-content collection for that account without deleting stored history or revoking provider authorization. Availability depends on provider support, required scopes, plan access, provider limits, and operator read policy as distinct facts. Existing accounts keep current Analytics behavior after upgrade. The Analytics page reads saved data, so it does not wait for each platform to reply. #### What the page shows - Account follower history and the latest account counters, labeled as account-wide - One content inventory for results published with OpenPost and eligible results published elsewhere - `All content`, `Published with OpenPost`, and `Published elsewhere` source filters for content totals, measured insights, and content rows - Views, impressions, reach, and provider-native measurements with their unit, aggregation meaning, reporting period, and collection time - Deterministic measured insights with the reporting range, measured count, comparison sample, and exact content evidence - Coverage notices that explain when history begins and whether provider caps, permissions, installation date, cost policy, or failures make it partial - Results for 7, 30, or 90 days, with account and content filtering and sorting - A **Repurpose** action that prepares a new unsaved composer state and opens direction review before any AI request - The last successful update and when OpenPost can try again A missing number is not shown as zero. Platforms define these numbers in different ways, so OpenPost keeps views, impressions, and reach separate. #### Collection timing OpenPost updates analytics in the background for accounts where the feature is enabled. No Redis service is required. - Account numbers start with one update per day. - Eligible account-content discovery processes one bounded page per durable job. Initial discovery targets at most the last 90 days and 250 items; each provider can return less. - Routine discovery runs no more than daily and respects stored backoff, provider concurrency, per-account read budgets, and `Retry-After`. Manual refresh does not bypass those limits. - Posts under 6 hours old update each hour. - Posts from 6 to 24 hours old update every 3 hours. - Posts from 1 to 3 days old update every 12 hours. - Posts from 3 to 7 days old update once a day. - Automatic post updates stop after 7 days. If the numbers do not change, OpenPost checks less often, up to eight times the normal wait. **Refresh data** asks for new account numbers and posts from the last 90 days. Platform API limits can delay an update. OpenPost saves each result once even if the same background task runs twice. #### Provider coverage | Provider | Account metrics | Publication metrics | Notes | | ------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | X | Followers, following, posts | Likes, replies, reposts, quotes, bookmarks, impressions | X API access and limits apply. | | Mastodon | Followers, following, posts | Favourites, replies, reblogs | Counts come from the connected instance. | | Bluesky | Followers, following, posts | Likes, replies, reposts, quotes | Public AppView endpoints are used. | | LinkedIn | Followers | Impressions, reach, reactions, comments, reposts, saves, and clicks for profiles; Organization Pages return the numbers LinkedIn makes available | Requires LinkedIn Community Management API access and the account-specific analytics permissions. | | Threads | Followers | Views, likes, replies, reposts, quotes, shares | Requires `threads_manage_insights`. | | Facebook Pages | Followers | Reactions, comments, shares | OpenPost does not use deprecated impression metrics. | | Instagram professional accounts | Followers, media count | Likes, comments, views, reach, saves, shares when returned | Publication insights require `instagram_manage_insights`. | | TikTok | Followers, following, likes, videos | Likes, comments, shares, views | Requires `user.info.stats` and `video.list`. | | YouTube | Subscribers, videos, channel views | Video views, likes, comments | Hidden subscriber counts may be unavailable. | You may need to reconnect Instagram, Threads, TikTok, or YouTube accounts that were added before OpenPost requested the current analytics access. OpenPost keeps the last good result while you restore access. A plan restriction is shown separately and does not imply that reconnecting will fix billing access. Pinterest, Telegram bot mode, and Discord bot mode have implementation paths but remain unavailable as public Hosted claims without current approval and live certification. Discord incoming webhooks remain the publicly documented Discord connection and do not support Analytics. #### Stored data and privacy OpenPost stores normalized account and content measurements, metric meaning, safe sync state, discovery coverage, opaque checkpoints, and bounded title and text for eligible discovered content. The title is limited to 500 characters and text to 10,000 characters. Safe provider links and stable provider-content references can also be retained. OpenPost does not store raw analytics provider responses, remote media bytes, access tokens, bot tokens, webhook secrets, or direct messages in analytics records. Account-content discovery jobs contain only OpenPost Workspace and Social Account references. Provider cursors remain in server-side discovery state and never enter page URLs or job payloads. The Repurpose request sends a discriminated opaque content reference and range in a POST body; it does not place post text, metrics, or provider payloads in a URL. Workspace access is checked again before any source or evidence is returned. Analytics, Direct messages, Comments and replies, and Grow are separate per-account choices. The opt-in inbox can collect Direct messages from X, Bluesky, Facebook Pages, Instagram, and Mastodon when enabled. See [Engagement, Inbox, and Notifications](https://docs.openpo.st/usage/communications.md) and [Accounts](https://docs.openpo.st/usage/accounts.md). Grow shows recommendations for eligible accounts and never follows automatically. ### Auto Reposts Source: [https://docs.openpo.st/usage/auto-reposts.md](https://docs.openpo.st/usage/auto-reposts.md) This page is for people configuring native same-network repost rules after publication. Auto repost rules let a connected account use its network's native repost action after one of your posts publishes. OpenPost supports native reposts on X, Mastodon, Bluesky, and LinkedIn. The source and target must be on the same network. OpenPost does not copy the post to a different network. #### Create a workspace rule Open **Settings → Workspace → Reposts**, then select **Add rule**. A rule contains: - source accounts whose published posts can trigger the rule; leaving the list on **Any compatible source account** covers every supported account in the workspace; - one or more target accounts that should repost; - a delay before the first evaluation; - an evaluation window after which OpenPost stops waiting; - optional minimum likes, comments, reposts, and views; - **Require all** or **Require any** when more than one minimum is set; - an optional stable-growth check that waits for the stored engagement totals to remain unchanged for several checks. Save the page to activate the rules. Disabled rules remain saved but do not create candidates for newly published posts. #### How engagement gates work The worker reads the latest saved publication analytics. Opening Settings or the composer does not call a social network. A minimum of `0` disables that metric. A provider-reported zero is a real value. A missing metric is not treated as zero and cannot pass an enabled gate. This distinction prevents a post from being reposted merely because its network does not report the selected number. After the delay, OpenPost checks again on a durable job. If the gates have not passed, it keeps checking until the evaluation window ends. Restarting the server does not lose the candidate. A native repost write runs once; an ambiguous network timeout is recorded as failed instead of being retried and risking a duplicate action. Each attempt counts toward the workspace's monthly provider-write limit. If that limit is reached, OpenPost skips the repost and records the reason without calling the network. #### Override one post Open **Repost settings** in the publication composer. - **Use workspace rules** applies every matching enabled rule. - **Do not repost** disables automation for that post. - **Custom** replaces workspace rules with the target accounts, timing, and engagement gates selected in the composer. The override is part of the saved publication, so it survives draft edits, scheduling, OpenPost Image Editor round trips, and restarts. #### Use an account from another workspace Workspace admins can select a target account from another workspace they also administer. Saving creates a scoped repost grant. The account token stays encrypted under its owning workspace and is never copied into the source workspace. Admins of the target workspace can revoke the grant from **Settings → Workspace → Reposts**. Revocation stops future reposts through that account. It does not remove reposts already published on the network. ### Engagement, Inbox, and Notifications Source: [https://docs.openpo.st/usage/communications.md](https://docs.openpo.st/usage/communications.md) This page is for people reviewing and responding to saved engagement and messages in a Workspace. OpenPost checks social networks in the background and saves the results. Engagement at `/inbox/engagement` and Messages at `/inbox/messages` read saved data, so a slow or unavailable network does not block either page. Direct messages, Comments and replies, Analytics, and Grow are optional and per connected account. Each feature starts off for a newly connected account. Choose them after connection or in Account details. Disabling a feature stops future provider reads and writes without deleting history or revoking provider authorization. Provider support, required scopes, and plan access remain distinct. Grow never follows automatically. Existing accounts keep their current behavior after upgrade. #### Engagement Comments and replies is an optional feature per connected account. It starts off for a newly connected account. Enable it after connection or in Account details to let OpenPost collect replies for eligible published Renditions. Disabling it stops future collection and provider actions for that account without deleting stored replies and without revoking provider authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. Existing accounts keep current Engagement behavior after upgrade. The Engagement page at `/inbox/engagement` brings comments and replies from supported posts into one list. It only shows the actions that each platform supports. | Provider | Read | Reply | Like | Hide or moderate | Delete own | | ---------------------- | ---- | ----- | ---- | ---------------- | ---------------------- | | X | Yes | Yes | Yes | No | Yes | | Mastodon | Yes | Yes | Yes | No | Yes | | Bluesky | Yes | Yes | No | No | Yes | | LinkedIn | Yes | Yes | No | No | Where LinkedIn permits | | Threads | Yes | Yes | No | Yes | No | | Facebook Pages | Yes | Yes | No | Yes | Yes | | Instagram Professional | Yes | Yes | No | Yes | Yes | | YouTube | Yes | Yes | No | Moderate | Yes | The page groups replies under the OpenPost post and connected account. You can filter by platform, account, post, read state, and archive state. The post filter searches saved Publications and loads older results in pages. Select **Load older** below the Engagement list to reach the complete saved history. A failed first page or older page stays in place with a retry action. Successfully loaded pages keep the active filters, open reply form, and visible reply position. Each item keeps safe file links, edit and delete state, and a link to the post on the social network. OpenPost does not save full platform replies or file contents. OpenPost checks new posts more often and older posts less often. It keeps the last good data if a platform reports a permission error, API limit, unsupported action, or short outage. Replies and moderation run as saved background jobs, not during a page load. OpenPost only shows Like for X and Mastodon because those are the verified like actions. #### Unified inbox Inbox connects Direct messages and Comments and replies as two separate per-account choices that may appear together under an Inbox heading. Direct messages is an optional feature per connected account and starts off for a newly connected account. Enable it after connection or in Account details to let OpenPost collect conversations and send messages for that account. Disabling it stops future message collection and sending for that account without deleting stored messages and without revoking provider authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. Existing Inbox opt-ins become Direct messages choices after upgrade, and other accounts receive explicit off choices. Inbox connectors for Direct messages are available for X, Bluesky, Facebook Pages, Instagram Professional accounts, and Mastodon direct-visibility posts. The inbox saves conversations, messages, file links, send status, and reply deadlines. It does not save full platform replies or tokens. If a platform sends the same message ID twice, OpenPost saves it once. The conversation list loads older conversations in stable pages and keeps the active Workspace, platform, account, archive filter, and selected conversation. If an older page fails, the loaded list stays available with an in-place retry. Open a conversation to start with its newest saved messages. As you approach the top, OpenPost loads older messages in stable pages and keeps the message you were reading in the same place. **Load older messages** provides the same action for keyboard and assistive technology users. A failed older page leaves the active history in place so you can retry it. New messages and late responses from another conversation or Workspace do not reorder or replace the history you have open. Important provider behavior: - Facebook and Instagram replies are limited by Meta's active customer-service window. OpenPost blocks a send after the stored deadline. - Mastodon direct posts are not end-to-end encrypted. The mentioned accounts and involved servers can read them. - Bluesky chat requires an app-password session. - X requires the app and connected account to have direct-message access. OpenPost only checks supported platform APIs for accounts where the feature is enabled, and it checks the effective state again before a Job contacts the provider. Direct messages and Comments and replies are enforced separately. Messaging stays separate from publishing, so more inbox platforms and file types can be added later. #### Personal notifications OpenPost sends in-app alerts for failed posts, accounts that need help, new replies, new messages, failed replies, workspace invites, and successful posts. If only some accounts fail, the alert names the accounts that worked and failed and links to the right fix. Retry only runs for failed accounts that can be tried again. Open **Settings → Personal → Notifications** to choose Off, Immediate, or Daily email for each optional event. In-app notifications remain immediate. Email is Immediate by default for failed publishing and failed replies. Successful posts, account attention, new engagement, and new messages stay in the app unless you change their email frequency. Critical failure alerts always remain on in the app. Daily email defaults to 09:00 in the browser timezone for a new choice. The settings page saves the time with an explicit IANA timezone, such as `Europe/Lisbon`, and does not replace an existing choice when you use another browser. Changing that saved window moves pending items that have not started delivery to the next occurrence of the new window. The old scheduled Job then sends nothing. OpenPost batches daily items for one user and local delivery window, deduplicates repeated events, and escapes notification content before rendering it. Email alerts use the same SMTP, Resend, or Cloudflare Email provider as account messages. OpenPost stores immediate and daily delivery as database Jobs, retries temporary failures up to five attempts, and checks the current preference before sending. A daily batch advances only after the mail provider accepts the send; a retry uses the same idempotency key. If delivery reaches its retry limit, the Job remains failed and its pending daily items remain available for operator diagnosis instead of being marked sent. If an administrator has not configured email delivery, the settings page shows email as unavailable. Security actions, access changes, Workspace invitations, and critical billing actions are Transactional notifications. They bypass optional email preferences and daily timing. Their email frequency remains Immediate and cannot be changed to Off or Daily. You can also start a temporary Mute for every Workspace or only the selected Workspace. Choose an exact future end time on the **Notifications** page or in **Settings → Personal → Notifications**. Both surfaces show each active scope and its end time, remove it when that time passes, and offer **End now** to restore your saved email frequencies immediately. Repeating **End now** for the same existing Mute is safe. Expiry does the same automatically; a Mute is an overlay and never rewrites those choices. Workspace-bound API and CLI credentials can create, read, reconcile, and end only Mutes for their bound Workspace; they do not receive account preferences or Mutes from another scope. A Workspace Mute is more specific than an account-wide Mute. When both apply, OpenPost resolves the Workspace Mute first; after it expires or ends, an active account-wide Mute applies. OpenPost compares the saved absolute end times, so the result does not change with a browser timezone. Optional Immediate and Daily email created or delivered while the effective Mute is active is skipped rather than sent later. In-app notifications remain immediate, and Transactional security, access, invitation, and critical billing email always bypasses Mutes. Workspace invites for an existing user do not store the raw invite token in the alert. The Notifications page shows alerts for the selected workspace plus account-wide notices such as workspace invitations. Marking the inbox read or deleting its history changes the workspace alerts in that inbox and its account-wide notices; alerts tied to other workspaces stay unchanged. Because account-wide notices appear in every workspace, reading or deleting one applies everywhere it appears. Deletion is permanent. The sidebar bell and Notifications page at `/inbox/notifications` share the same selected-workspace inbox. Successful read and delete actions update both immediately; failed actions keep the prior unread state so you can try again. While the app is open, it checks for new alerts on a bounded timer and when the window regains focus. The feed loads older alerts in pages, keeps the same read-status filter while loading, and offers a separate retry for the first page and for an older-page failure. Alerts are grouped under Today, Yesterday, or their full date, and each row names its event type, read state, time, and available actions. Alert settings do not control inbox collection. Turn inbox collection on or off from the social account. ### Composing Posts Source: [https://docs.openpo.st/usage/composing-posts.md](https://docs.openpo.st/usage/composing-posts.md) This page is for people creating a Publication in the composer. OpenPost uses one text-and-thread composer for every publication. Add another post to create a thread. Media and connected accounts determine each destination's format. The Workspace setup guide advances when you enter non-empty text, attach media, or intentionally add a thread post. Opening or focusing the composer, choosing a destination, and saving an empty draft do not count as starting a composition. This progress is stored once per Workspace and remains complete after refresh. #### Build with AI Choose the destinations first so OpenPost can write a source draft and native Renditions for the accounts you selected. Use **Build with AI** when you already have an idea. Use **Ideate** when you want help finding one: add an optional brief, choose **Get ideas**, select an option, continue, then compare five distinct directions before you choose **Build native drafts**. The build runs as a durable Job. You can close the window, resume a running build, cancel it, or retry a stopped build without losing the selected idea, direction, or current attachments. OpenPost does not replace the composer while the Job runs. If you edit the draft before the result arrives, choose **Review and apply** to compare the versions or **Keep my edits** to leave the current draft unchanged. Review the shared draft and every destination Rendition before you schedule or publish. Self-hosted instances need `OPENROUTER_API_KEY`; see [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md#core-settings) for the model, provider, and data-retention controls. #### Steps 1. Write the shared content and add media. 2. Choose a Social Set or adjust the accounts in **Destinations**. 3. Complete the required fields shown in **All channels**. 4. Open an account tab to preview it, customize its content, or change its platform settings. 5. Fix any errors, then schedule or publish. Open **Advanced delivery** to change repost behavior for this publication. Keep **Use workspace rules**, choose **Do not repost**, or select **Custom** to replace the target accounts, delay, evaluation window, and engagement gates. The override is saved with the draft and follows it through scheduling and publishing. OpenPost infers posts, threads, links, documents, image sets, videos, Shorts, and other destination formats from the content. Instagram and Facebook ask for a format only when the same media can validly become more than one format. TikTok photo posts and videos are inferred from the media. When automatic alt text is enabled, adding an image without saved alt text drafts shared alt text without blocking the attachment or publication if the request fails. When present, up to 1,000 characters of the current relevant post or thread segment are sent with the thumbnail as untrusted context for better disambiguation. Review or replace the draft, then use account settings for destination-specific text. See [Media](https://docs.openpo.st/usage/media-library.md#automatic-alt-text) for the processing and privacy details. Use **Create** in the media picker to open the Image Editor. Select one saved video and choose **Edit in OpenPost Video Editor** to create a local project with that source, or choose **Create video** with no selected video. OpenPost saves the Publication before leaving. After you send a finished export to Media, **Open composer** returns to that Publication and attaches the exact uploaded file. The Video Editor project, sources, and other exports remain in the disk workspace. See [OpenPost Video Editor](https://docs.openpo.st/usage/video-editor.md#send-an-export-to-openpost) for the local-storage and upload boundary. #### Drafts and account versions A draft keeps the shared text, thread parts, and media. Each selected account has its own format, nullable field and media overrides, platform settings, thread changes, and optional schedule override. Changing a shared field updates every destination that still inherits that field. An explicit empty destination field remains empty. The destination strip shows **All channels** followed by one tab per account. A tab keeps an included but invalid destination visible and shows its issue count. The summary shows how many destinations are ready. Use **Use shared**, **Use shared media**, or **Reset inherited fields** to remove only the intended overrides. The format field appears only when an account has a meaningful choice. OpenPost can change an inferred format as the source changes, but it never replaces a format you selected explicitly. YouTube Shorts require complete duration and orientation metadata; otherwise OpenPost uses a regular YouTube video. #### Social Sets A Social Set is a reusable, format-independent account group. It can define a workspace default. Selecting one fills the destination strip; you can still add or remove accounts for the current draft. The draft copies the set's current accounts. Editing or deleting the Social Set later does not add, remove, or rewrite destinations in an existing draft or scheduled publication. Deselecting an account keeps its saved version and settings. Use **Delete account version** only when you want to remove them. Each save includes the draft version loaded by the editor. OpenPost saves shared content, thread parts, media, selected accounts, custom text, and settings together. It finishes one save before starting the next. Scheduling and publishing wait for an active save. If another tab or teammate saves first, OpenPost stops instead of overwriting their work. The conflict dialog lists the affected areas and lets you: - reload the saved version; - save your current work as a new draft; - overwrite only after reviewing the latest revision; - keep editing without taking an action yet. When OpenPost knows who made the latest change, the dialog names that person without showing draft content or other workspace data. **Overwrite** first loads the latest saved version, then saves your copy over it. Closing or hiding a tab triggers a best-effort save, but browsers do not guarantee unload requests. Wait for the saved state before closing when the content matters. #### Platform previews The **Destinations** control shows each selected account, including more than one account from the same platform. Destination tabs expose the chosen format and inherited or custom content, with direct **Preview** and **Platform settings** actions. Platform settings show only the options that fit the current account, format, and media. You can save an incomplete draft. Before scheduling or publishing, OpenPost checks the account again. It blocks old privacy choices, removed playlists, expired access, invalid media, and conflicting files. See [Account Options](https://docs.openpo.st/usage/destination-options.md) for the options each social network supports. #### Practical advice - Keep the shared text simple. Change it only when an account needs a different version. - Review shared alt text, then use account settings for destination-specific text and media tags. - Fix all errors before scheduling. A warning may point to app review, API limits, or account limits that OpenPost cannot change. ### Account Options Source: [https://docs.openpo.st/usage/destination-options.md](https://docs.openpo.st/usage/destination-options.md) This page is for people tailoring one Publication for each selected social account. The editor starts with the kind of post you want to make. It then finds the right format for each social network. - **Post type** is Post, Thread, Story, Short video, or Video. - **Content** can be text, a link, images, mixed media, a document, or a video. - **Network format** is the exact format OpenPost sends, such as `instagram.carousel`, `linkedin.document`, `tiktok.photo`, or `youtube.short`. - **Options** can apply to an account, a part of a thread, or one media file. Open the settings beside a selected account to change its text and options. OpenPost only shows sections that apply: Content, Conversation, Distribution, Disclosure, and Media and accessibility. #### Where options apply | Area | Applies to | Examples | | ---------- | --------------------------------------- | ------------------------------------------------------- | | Account | The selected connected account | Privacy, visibility, playlist, category, reply audience | | Segment | One post in a thread or follow-up chain | Poll, poll duration, first comment | | Media item | One attached image or video | Alt text, tagged users, product tags, focal point | Settings are not silently removed when content changes. A conflict stays visible until you remove the setting or the conflicting content. #### Options by social network | Social network | Formats | What you can set | | ---------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | X | Post, link, images, video, thread | Poll, reply audience, community and location when granted, paid-partnership and AI disclosures, tagged users, and alt text. Quote publishing remains unavailable without X Enterprise access. | | Mastodon | Post, link, media, video, thread | Instance-derived text, media, and poll limits; visibility; content warning; sensitive media; language; poll controls; alt text; and focal point. Quote and interaction policy controls remain unavailable until the instance advertises compatible support. | | Bluesky | Post, link card, images, video, thread | Who can reply, thread rules, languages, labels, quote link, link card, and image alt text. | | LinkedIn | Post, article, image, multi-image, document, video, poll, root plus comment | Visibility, reshares, article details, document title, image alt text, poll choices, and a first comment. Thumbnail and caption upload are not available here. | | Facebook Pages | Text, link, photo, multi-photo, video, Reel, Story | Link and video details, Reel feed sharing, and a first comment. Page polls, text backgrounds, and thumbnails are not available. | | Instagram Professional | Feed image, carousel, Reel, Story | Other authors, location, people tags on each image, alt text, Reel cover or frame, share to feed, and trial Reel controls when the account supports them. Product tags only appear for accounts that support them. | | Threads | Post, link, image, video, carousel, thread | Poll, who can reply, topic, location, media warning, link, long text, ghost posts, and reply approval when the account supports them. GIF publishing works, but the picker needs a GIPHY search service. | | YouTube | Short, Video | Required title, category, and privacy; description; tags; playlist; thumbnail; captions and language; license; embedding; child-directed, synthetic-media, and paid-placement disclosures; and subscriber notification. | | TikTok | Video, photo post | Direct Post or inbox upload, live privacy and interaction choices, photo title and cover, auto-music, video cover frame, branded and organic disclosures, music consent, and video AI disclosure. Privacy is required only for Direct Post. | #### Choices that can change OpenPost checks these account choices again before it schedules or publishes: - TikTok account choices after 5 minutes. - YouTube playlists and categories after 15 minutes. - Mastodon server settings after 1 hour. OpenPost never treats a sign-in or permission error as a success. If it cannot load required account choices, the account panel shows **Retry** and blocks publishing. If the missing choices are optional, it shows a warning. #### Compatibility YouTube accepts only Short video and Video. Multiple images use Post rather than a separate carousel type. A video added to Post requires Short video or Video. App review, permissions, account type, API limits, and public media links can still block a format. ### Grow Source: [https://docs.openpo.st/usage/grow.md](https://docs.openpo.st/usage/grow.md) Use this page when reviewing account recommendations for growth. Grow suggests accounts to follow for eligible connected accounts. It is an optional feature per connected account. #### Optional feature per account Grow starts off for a newly connected account. Enable it after connection or in Account details. Disabling it stops future recommendation discovery and provider follow actions for that account without deleting stored recommendations and without revoking provider authorization. Availability depends on three distinct facts: provider support for that account, required provider scopes, and plan access. An unsupported feature is omitted. A missing scope tells you to reconnect with additional permission. A plan restriction stays a billing matter and does not imply that reconnecting will fix it. Existing accounts keep their current behavior after upgrade. Grow becomes enabled only where OpenPost already has stored Grow sync state for that account, and other accounts receive explicit off choices so the prompt does not appear on routine reauthorization. #### Provider coverage | Provider | Discovery | Follow | Notes | | ------------------------------------------------------------------- | --------- | ------ | ------------------------------------------------------------------------------------------------------------------------- | | Bluesky | Yes | Yes | Discovery uses follows and suggestions, then enriched profiles. Following requires the account to be enabled for Grow. | | Mastodon | Yes | Yes | Discovery uses suggestions, familiar followers, and relationships. Following requires the account to be enabled for Grow. | | X, LinkedIn, Threads, Facebook, Instagram, TikTok, YouTube, Discord | No | No | These providers do not expose Grow discovery or follow through OpenPost. | #### How it works - Enabling Grow queues an initial durable discovery refresh for that account. The Grow page then populates from saved data. - Disabling Grow removes that account from the Grow account selector and stops queued discovery and follow work for that account. Stored recommendations stay visible but no new provider calls are made. A queued Job rechecks the effective state before contacting the provider. - Following a recommendation always requires Grow to remain enabled for that account and remains an explicit user action. OpenPost never follows an account automatically. - The Grow page reads saved recommendations and shows notices for reconnects, unsupported accounts, missing permission, plan restrictions, and update errors. The account selector scopes recommendations to one connected account. Use **Show** to focus on people who already follow that account, **Minimum mutuals** to require stronger shared-network evidence, and **Sort** to choose Best match, Follow-back potential, or Most mutuals. Reset returns all controls to their defaults and the result count always reflects the current choices. Follow-back potential is an estimate, not a promised probability. It puts known followers first, then uses mutual connections and the balance between follower and following counts. OpenPost does not display a percentage because providers do not supply enough evidence for a reliable probability. #### Stored data OpenPost saves recommendation data and its update status for the account. Grow records do not store provider tokens, post text, or raw provider responses. Changing the feature choice does not revoke provider authorization. Use provider settings or account removal to revoke provider authorization. See [Accounts](https://docs.openpo.st/usage/accounts.md) for choosing features after connection or in Account details, and [Analytics](https://docs.openpo.st/usage/analytics.md) and [Engagement, Inbox, and Notifications](https://docs.openpo.st/usage/communications.md) for the other optional account features. ### OpenPost Image Editor Source: [https://docs.openpo.st/usage/image-editor.md](https://docs.openpo.st/usage/image-editor.md) OpenPost Image Editor is OpenPost's still-image editor for social posts, carousel pages, and slideshow images. Use the free public editor at app.openpo.st/image-editor, or open the workspace version from **Media → Create → Create design**, from a Media asset's detail view, or from the composer's media picker. #### Use OpenPost Image Editor without an account The public editor works without sign-in and adds no watermark. Start with a social format, an OpenPost template, or a PNG, JPEG, or WebP image. Designs and imported images stay in that browser. Clearing site data, using private browsing, or letting the browser clear old data can remove local work. You can return to local designs on the same device and export PNG, JPEG, or WebP files at any time. **Save to OpenPost** creates an account or uses your current account, uploads the local images, and copies the editable design into your workspace. The local copy remains available in the original browser. #### Create and edit Start with a social preset, an original OpenPost starter template, a workspace template, or an existing image. A design can contain up to 35 ordered pages. Each page has a solid, transparent, gradient, or image background and ordered text, image, shape, paint, and group layers. Desktop OpenPost Image Editor provides the canvas, asset and brand panes, Layers, Properties, and the page strip at the same time. On a phone, use the bottom tool rail and open one editing sheet at a time. The mobile editor supports adding and transforming layers, crop and image adjustments, text and shape properties, layer order and visibility, page management, undo and redo, background removal, and export. OpenPost Image Editor saves soon after you stop editing. Public designs stay on the current device. Workspace designs are saved to OpenPost. Changes that have not reached the server also stay in this browser for seven days. If another browser changes the same design, OpenPost Image Editor does not overwrite it. You can reload the saved version, save your local work as a copy, or keep editing locally. #### Versions and templates Open **File → Version history** to inspect an earlier workspace version or save a named version. The list shows when each version was saved and who saved it. Select a version to load its preview and a summary of material page, layer, guide, title, size, export, and cover changes. OpenPost does not fetch the full version until you select it. Choose **Load more** to reach older named versions when the design has more history than the first page. OpenPost keeps at most 20 automatic recovery versions for up to 30 days. Named versions and restore points do not expire automatically; they remain with the design. Before restoring, OpenPost confirms that it will save the exact current design as a restore point. The target pages and cover then replace the current head in one revision-checked transaction. Media referenced only by the target version is recovered from workspace trash as part of that transaction. If target media is missing or belongs to another workspace, the restore stops before changing the design. You can restore the automatic **Before restore** version to return to the prior head. If the design changes in another browser before the transaction commits, the restore stops without changing the design or its trashed media. Reload the current OpenPost version before trying again, or preserve the browser's unsaved work as a separate design. Restoring one version does not delete the other named versions or restore points. You can save a design as a new workspace template or replace a template. A design made from a template is its own copy. Later changes to the template or brand kit do not change that design. #### Brand assets Manage the workspace brand kit from **Settings → Workspace → Brand**. It can hold named colors, default page backgrounds, whole-layer text styles, and custom WOFF2, TTF, or OTF fonts. You must confirm that you can use a custom font before upload. OpenPost previews it in the browser and checks its file type and size on the server. You cannot remove a font while a design or template uses it. The Image Editor exposes saved brand colors and fonts. Saved page backgrounds and whole-layer text styles remain available in the brand kit but do not yet have direct apply actions in the editor. #### Background removal Select an image layer and choose **Remove background**. OpenPost loads this feature only when you use it and works on the image in your browser. It uses your graphics hardware when available and your processor when needed. It does not send the image to another background-removal service. The result is a new transparent PNG in Media. The original stays unchanged, and you can undo the layer change. OpenPost may use a smaller temporary copy for a large image without changing the original. #### Export and composer return Export the current page or every page in order as PNG, JPEG, or WebP. A multi-page download uses a ZIP file. Media keeps the source design and page with each export. When OpenPost Image Editor opens from a post, OpenPost saves a local recovery copy and a return link that works for two hours. **Export and attach** adds the files in order to the same post, thread part, or thumbnail field and checks the account limits again. If OpenPost cannot return to the post, the exported files stay in Media. #### What OpenPost Image Editor supports OpenPost Image Editor edits still images only. It does not edit video, make animations, add any SVG or remote link as a layer, mix text styles in one layer, use CMYK or print units, let several people edit at once, or offer MCP editing tools. ### User Docs Source: [https://docs.openpo.st/usage/index.md](https://docs.openpo.st/usage/index.md) Use these docs to connect accounts, build or write account versions, schedule posts, track results, work with replies and messages, use the CLI, or grant an AI tool access through MCP. Provider data, formats, and actions remain limited by the connected account and provider. For a visual introduction, watch the OpenPost product demo. #### Web app The web app is the main place to work. - [Workspaces](https://docs.openpo.st/usage/workspaces.md) keep brands, accounts, prompts, schedules, and media separate. - [Settings](https://docs.openpo.st/usage/settings.md) explains the Personal, Workspace, Organization, and Instance split. - [Account Security](https://docs.openpo.st/usage/account-security.md) covers authenticator setup, one-time recovery codes, passkeys, and secure replacement or removal. - [Accounts](https://docs.openpo.st/usage/accounts.md) explains how connected social accounts appear in a workspace. - [Composing Posts](https://docs.openpo.st/usage/composing-posts.md) covers built-in AI drafting, account selection, media, account versions, and the editor. - [Agent-Assisted Publishing](https://docs.openpo.st/usage/agent-assisted-publishing.md) shows how an external MCP tool can prepare account versions while a person reviews the work. - [Threads](https://docs.openpo.st/usage/threads.md) covers multi-post sequences. - [Scheduling](https://docs.openpo.st/usage/scheduling.md) covers posting times, status, and errors. - [Auto Reposts](https://docs.openpo.st/usage/auto-reposts.md) covers native repost rules, engagement gates, delays, per-post overrides, and cross-workspace account access. - [Analytics](https://docs.openpo.st/usage/analytics.md) covers account growth, post results, platform access, and update times. Analytics is an optional feature per connected account and starts off. - [Engagement, Inbox, and Notifications](https://docs.openpo.st/usage/communications.md) covers comments, replies, messages, and alerts. Direct messages and Comments and replies are separate optional features per connected account and start off. - [Grow](https://docs.openpo.st/usage/grow.md) covers recommendations for Bluesky and Mastodon. Grow is an optional feature per connected account, starts off, and never follows automatically. - [Media](https://docs.openpo.st/usage/media-library.md) covers reusable files, designs, templates, brand items, tags, file sources, use checks, and safe cleanup. - [OpenPost Image Editor](https://docs.openpo.st/usage/image-editor.md) covers the no-account editor, multi-page still-image designs, saved versions, background removal, export, and return to the post editor. - [OpenPost Video Editor](https://docs.openpo.st/usage/video-editor.md) covers disk-backed projects, multitrack editing, motion, color, audio, captions, local models, export, and Media handoff. - [Quick Cut](https://docs.openpo.st/usage/quick-cut.md) covers verified keyframe cuts, exact cuts, stream selection, merged output, and lossless packet-copy export. - [Recorder](https://docs.openpo.st/usage/recording.md) covers separate synchronized screen, camera, and microphone capture, recovery, and timeline insertion. #### CLI Use the CLI from a terminal, CI, cron, or a script that connects to a running OpenPost server. - [CLI Overview](https://docs.openpo.st/cli/index.md) explains the command model. - [Installation](https://docs.openpo.st/cli/installation.md) covers release binaries and source builds. - [Authentication](https://docs.openpo.st/cli/authentication.md) covers browser login, device flow, and API-token login. - [Posting](https://docs.openpo.st/cli/posting.md) covers posts, threads, media, rich publications, and `next-slot`. - [Automation](https://docs.openpo.st/cli/automation.md) covers CI and recurring jobs. - [Command Reference](https://docs.openpo.st/reference/cli.md) is generated from the Cobra command tree. #### MCP MCP lets an AI tool use the access you grant. Use it when ChatGPT, Claude, Cursor, Codex, or another tool should read workspace data, create drafts, tailor account versions, or schedule posts. - [Use OpenPost With an AI Assistant](https://docs.openpo.st/mcp/index.md) explains MCP access and safe use. - [MCP and ChatGPT App Developer Notes](https://docs.openpo.st/development/mcp.md) cover implementation details for contributors. #### Where not to look - Server setup, backups, social app keys, and server settings live in [Self-Hosting](https://docs.openpo.st/self-hosting/index.md). - Code structure, backend and frontend details, API generation, and tests live in [Developer Docs](https://docs.openpo.st/development/index.md). ### Media Source: [https://docs.openpo.st/usage/media-library.md](https://docs.openpo.st/usage/media-library.md) This page is for people organizing reusable media and editor assets in a Workspace. Media keeps your files, OpenPost Image Editor designs, templates, and brand items in one place. #### Assets The Assets view contains image, video, and audio uploads, camera photos, generated memes, OpenPost Image Editor exports, edited copies, and images with the background removed. Search names, alt text, and tag names. Filter by one or more tags, untagged assets, media type, source, size, shape, or date. Use tags for flexible organization: one file can have several tags, and choosing several tag filters shows files that have all of them. Select files to add or remove a tag, mark favorites, or delete a group. Deleting a tag does not delete its files. Open a file to see its preview, type, size, source, date, alt text, tags, original file, design, and where it is used. Editing an image makes a new OpenPost Image Editor design or a new copy. It never replaces the original. New uploads remain untagged until you add tags. When one tag filter is active in Media or a media picker, files uploaded there can be added directly to that active tag. Composer paste and drop uploads remain untagged. OpenPost will not delete a file while a post, design, template, brand item, font, preview, or export still uses it. The file page shows each use so you can remove it first. Cleanup uses the same rules. #### Media lifecycle OpenPost uses one fixed cleanup policy. Post-specific temporary media moves to Trash after its final successful publication or after 14 days without use. Favorites, tags, collections, brand files, active drafts and schedules, retryable work, source relationships, and live OpenPost Image Editor projects keep their media safe. Disk-backed Video Editor projects are separate from Media and do not create hidden retention references. Items stay in Trash for seven days before permanent removal. Restoring an item restarts its unused period. You cannot change or disable either period in workspace settings. #### Add media to a post Each post editor uses the same media picker: - **Library** uses a saved file without uploading another copy. - **Upload** adds a file from your device. - **Camera** captures a still image after browser permission. - **Meme** opens OpenPost's built-in catalog, lets you fill every caption and replaceable image slot, and saves the locally rendered result in Media. - **Create** saves your post and opens [OpenPost Image Editor](https://docs.openpo.st/usage/image-editor.md). OpenPost keeps files in the order you choose. It checks the file types and count against the rules for all selected accounts. If AI suggestions are configured, describe the joke and choose a tone to get several editable template and caption options. OpenPost sends only the idea and a bounded shortlist of names and written template notes to the configured model. It never sends the template images or your replaceable workspace images. Rendering stays inside OpenPost. Review the result, alt text, template source, and your right to publish the template before attaching it. Instance setup and the AI boundary are in [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md#meme-maker). ##### Automatic alt text If your instance operator has configured OpenRouter, adding an image with no saved alt text to the text-and-thread composer asks OpenPost to draft shared alt text. The server sends a 400px JPEG thumbnail and, when present, up to 1,000 characters of the current relevant post or thread segment to OpenRouter and an eligible provider that declares it does not collect request data. OpenPost treats the text as untrusted context for better disambiguation, not as model instructions. It does not send the original image for this task. OpenPost saves the caption only while the shared alt text is still blank, so existing text and edits made while the request runs always win. Review the result and adjust it before publishing. You can also customize alt text for a specific account. A missing API key or a captioning error does not stop the image from being attached or published. The thumbnail and any relevant segment text leave the OpenPost instance for this external processing. Instance operators can review the full privacy boundary and setup in [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md#automatic-image-alt-text). #### Prepare and edit video OpenPost checks a video in your browser before upload. It keeps a compatible H.264/AAC MP4 as it is. If needed, it changes or shrinks the video to fit the selected accounts. Audio files upload directly when the active composer accepts them. The strictest size, length, file type, and shape rules apply. For one video, you can trim the start and end, preview the result, and crop it for the selected accounts. Drag the crop area or move it with the arrow keys. Use zoom to choose what stays in view. Some browsers can trim a compatible file but cannot crop it. Video changes happen in your browser. The upload view shows progress and lets you cancel. After upload, the server checks the file and makes a poster image. You cannot schedule or publish the video until this check passes. A failed check stays in Media with the error and a retry button. OpenPost then sends the video in the way each social network requires. Threads, Facebook, Instagram, and some TikTok posts download the file from a public HTTPS link. #### Storage and source files Uploads, camera photos, exports, edited copies, brand files, and custom fonts count toward the workspace storage limit. Hidden design and template previews do not count. Each edited copy keeps a link to its source file and OpenPost Image Editor page when relevant. Deleting a design does not delete its exports. You can delete an original only after nothing else uses it. Saved edited copies remain. Keep media available until posts to Threads, Facebook, Instagram, and some TikTok accounts finish. If you run OpenPost with S3 or R2, see [Media Storage](https://docs.openpo.st/configuration/media-storage.md) for the browser upload rule. ### Quick Cut Source: [https://docs.openpo.st/usage/quick-cut.md](https://docs.openpo.st/usage/quick-cut.md) Quick Cut removes ranges from one or more media files with the least processing possible. Open `/quick-cut`, choose source files, mark the sections to keep, and export them individually or as one merged output. Use [OpenPost Video Editor](https://docs.openpo.st/usage/video-editor.md) when the edit changes pixels, speed, audio levels, transitions, captions, overlays, or effects. #### Make a cut Open one or more video or audio files. Set In and Out points from the player or time fields, then add a segment. Segments can be enabled, reordered, previewed in sequence, and looped. The stream panel shows the video, audio, subtitle, data, and unknown tracks found in each source. **Nearest keyframe** snaps an eligible segment start to a verified source keyframe so encoded packets can be copied. **Exact time** keeps the requested boundary and uses precise transcoding when stream copy cannot represent it. The preflight states which segments can be copied, which require encoding, and why an output is blocked. #### Export without hidden loss Stream copy keeps supported encoded video and audio packets instead of decoding and encoding them again. It preserves the compatible source container and codecs, so an eligible MOV stays MOV and an eligible WebM stays WebM. It also avoids the generation loss and render time of a transcode. Quick Cut rejects plans that would silently drop unsupported subtitle, data, unknown-audio, or extra-video streams. Merged export requires compatible selected streams across its sources. If exact boundaries or incompatible inputs require rendering, choose the precise path or move the edit to the full editor. Exports can download directly or save into the selected Video Editor workspace. A Quick Cut project file keeps source metadata, segment order, cut mode, and export choices. Reopening still requires access to the original source files. #### Send to OpenPost When signed in with an OpenPost Workspace selected, **Send to OpenPost** uploads the finished output to Media. It does not upload the Quick Cut project or source files. Quick Cut uses browser media and file APIs. Current Chrome or Edge gives the most complete file, codec, and workspace support. ### Recorder Source: [https://docs.openpo.st/usage/recording.md](https://docs.openpo.st/usage/recording.md) Open `/record` to capture screen, camera, and microphone sources as separate synchronized files. The same recorder is available inside OpenPost Video Editor, where finished sources can be added to the current timeline together. #### Choose sources Select any supported combination of screen, camera, and microphone. Camera and microphone use the chosen device or fall back to the current default if a saved device has disappeared. Video choices include 720p, 1080p, or 2160p and 24, 30, or 60 fps, subject to the device and browser. Screen capture uses the browser's own tab, window, or display chooser. System or tab audio is present only when the browser returns an audio track. OpenPost reports whether it is active instead of assuming that a checked option worked. Cursor visibility also follows the browser's reported capture setting. Microphone capture can request noise suppression and automatic gain control. A countdown and planned-length estimate help you prepare and reserve local space before recording starts. #### During capture The recorder shows elapsed time, chunk counts, bytes written, local space, and microphone level. Screen, camera, and microphone sources use one capture timebase so the editor can place them in sync. Chunks and a recovery manifest are written to local scratch storage while recording. Storage checks stop capture before the browser runs out of space. If a device disappears, the recorder tries the selected device and then the default. If recovery cannot continue, already flushed tracks remain available. The standalone recorder downloads each finished source file. Inside Video Editor, **Add to timeline** imports the files and places them as one linked, undoable edit. Discard removes recoverable scratch data when possible. #### Privacy and browser support OpenPost requests screen, camera, or microphone access only after you start recording. Captures stay on the device unless you later upload a finished export to OpenPost. Browser permission indicators and operating-system controls remain authoritative. Current Chrome and Edge provide the intended capture APIs. Available resolutions, frame rates, system audio, cursor options, and MIME types vary by browser, operating system, and selected device. ### Scheduling Source: [https://docs.openpo.st/usage/scheduling.md](https://docs.openpo.st/usage/scheduling.md) OpenPost saves scheduled posts in its database, so they survive a server restart. The same saved record keeps the shared content, account versions, media, time, and status. #### Plan from Calendar Calendar has month and week views. Filter it by workspace, platform, and status: - **Scheduled** items show when they should run and can be moved. - **Published** items show when they ran and cannot be moved. Select an empty future day to start a post with that date and time. On desktop, drag a scheduled item to another future day or time. On a phone or tablet, open the item and use its time controls. Calendar, Activity, and the side planner show the same posts. Each text post, thread, Story, short video, or video appears once and opens in the right editor. #### Choose a time in the composer The composer interprets dates and times in the workspace timezone. You can: - choose an exact future date and time; - request the next free slot from the workspace posting schedule; - save a time while the post stays a draft; or - choose **Schedule**, which checks the post and saves the posting job. The publication time applies to every destination by default. To stagger delivery, open a destination tab, expand **Advanced delivery**, and set its schedule override. OpenPost keeps the publication grouped in Calendar and Activity while each rendition runs at its own time and records its own result. After you schedule or submit, the composer keeps a result for each destination on screen. It identifies destinations that succeeded, remain pending, failed, or need review. Use **Retry destination** only when OpenPost offers it, or open **View publication** to inspect the complete delivery state before another action. For API, CLI, and MCP users: setting `scheduled_at` on a draft does not schedule it by itself. The schedule action creates the required destination jobs and marks the publication and renditions as scheduled. Clearing the schedule returns them to draft, removes destination schedule overrides, and keeps old success and error history. The schedule and immediate-publish HTTP responses include `publication_id` and `renditions`. Each rendition carries the canonical destination status and delivery evidence instead of inheriting one aggregate action result. Configure the workspace timezone and week start in **Settings → Workspace → General**. Manage reusable posting slots and the optional natural posting delay in **Settings → Workspace → Posting schedule**. #### Delivery and recovery OpenPost runs saved jobs from its database, so scheduled posts survive restarts without Redis. SQLite is the self-hosted default. The hosted service uses PostgreSQL. Open a Publication to inspect every destination Rendition separately. Each destination keeps one exact outcome: queued, submitted, processing at the provider, scheduled at the provider, live, rejected, awaiting reconciliation, or requiring manual resolution. The detail includes the latest attempt time and safe normalized failure code when one is available. OpenPost does not store or display raw provider responses because they can include sensitive data. **Retry destination** appears only after the provider write is known to be safe to repeat. OpenPost does not offer retry after an ambiguous write because another send could create a duplicate. It checks supported providers first; when it cannot reconcile the result, use **Review destination** to inspect the connected account and confirm the provider result before taking another action. Use **Activity** to see drafts and scheduled, published, or failed posts. Activity and Publication detail use the same state, provider-attempt evidence, reconciliation timing, and safe recovery action for each exact account and target. Publication history records when each lifecycle event occurred and names its destination. It also separates that occurrence from the latest effective destination outcome. When a later provider attempt replaces an older failure, the older event remains in the timeline as an earlier outcome and cannot look current. Retry and manual-review controls follow the latest effective outcome, not the historical event. #### What to watch - Keep the workspace timezone correct before creating schedules. - OpenPost checks platform limits and account access again when you schedule or publish. - A platform outage can leave an account failed or waiting for another try. Check Activity instead of assuming the post went live. ### Settings Source: [https://docs.openpo.st/usage/settings.md](https://docs.openpo.st/usage/settings.md) OpenPost groups settings by who or what they affect. #### Workspace Workspace settings belong to the selected workspace. - Connected social accounts - Workspace members, active or inactive access, pending invitations, and access history - Workspace timezone and week start - Fixed media lifecycle policy and current Trash behavior - Posting schedule and default slot behavior - Natural posting delay - Native auto repost rules, engagement gates, and account access grants - Brand colors, marks, text styles, and custom WOFF2, TTF, or OTF fonts Use this tab when the setting should differ between brands, clients, or projects. Invite people from **Settings → Workspace → Members**. Pending invitations reserve seats until they are accepted, revoked, or expired. Active workspace admins can change accepted roles, deactivate or restore access, permanently remove a member, resend or revoke invitations, search and filter the team, and review the access history. OpenPost blocks any change that would leave the workspace without an active admin. Inactive members cannot open the workspace and do not consume a seat. The media lifecycle information is not a workspace control. Temporary post-specific media moves to Trash after its final successful publication or 14 days without use, and Trash is permanently removed after seven days. Favorites, organized media, active work, and editor projects remain protected. OpenPost keeps the periods fixed so an old client or stored workspace value cannot weaken the policy. #### Personal Personal settings follow your user login across every workspace. - Display name and profile picture - Appearance, language, and interface sounds saved in the current browser - Optional public-profile fields and privacy preview - Sign-in email and its verified change flow - Password, two-factor authentication, and passkeys - Linked Google and organization sign-in identities - Active browser sessions - CLI devices and API tokens - MCP and ChatGPT App tokens/activity - Per-topic email frequency and the saved daily digest time and timezone - Temporary account-wide and selected-Workspace email Mutes with exact end times The Notifications page shows the same active Mutes and end-now action, so you can inspect or stop a Mute while reviewing notification history. Use an `mcp:read` token limited to one workspace when an AI tool only needs to read OpenPost. Use `mcp:full` only when it must create or change drafts and account versions, upload media, schedule, publish, cancel, reply, or moderate. You can remove either token. Check recent activity and remove access when the tool no longer needs it. Use this group when the setting is about you, not a workspace. Settings labels each boundary. Profile and security follow your account. Appearance, language, and sounds are saved in the current browser and apply to any workspace opened there; they do not sync to another browser or device. Timezone, week start, scheduling, and workspace identity stay with the selected workspace. Authenticator setup does not finish until you save and acknowledge the one-time recovery codes. The codes are shown once and each works for one sign-in. Viewing the remaining count, replacing the set, and disabling the authenticator require a recent password, passkey, or linked-identity check. See [Account Security](https://docs.openpo.st/usage/account-security.md) for the complete setup and recovery flow. OpenPost keeps account linking explicit. If a Google or organization account uses the same email as an existing OpenPost user, sign in with the existing method first. Open **Settings → Personal → Security**, confirm your current method, and link the external account there. You can then use either method to sign in. OpenPost blocks unlinking the last usable sign-in method. #### Organization Organization settings group hosted billing and shared identity administration. - Seat usage - Hosted service plan, usage, checkout, and billing links - OIDC identity providers, verified domains, SSO enforcement, provider assurance, and machine-token policy ##### Plan and billing ownership **Settings → Organization → Plan & usage** separates the facts OpenPost can show from the tasks Paddle owns. OpenPost shows its latest Paddle-backed plan, subscription status, billing contact, renewal or end date, and entitlement limits. It also shows OpenPost product usage for the current month. A fact stays hidden when the relevant provider snapshot does not contain it. Paddle manages payment methods, invoices, receipts, discounts, tax, cancellation, and billing details. OpenPost does not manage or display card details, invoice copies, or receipts. Organization owners and administrators can open each Paddle task from **Plan & usage**. OpenPost asks Paddle for an exact payment-method or cancellation link. Invoice and billing-detail tasks, or an unavailable exact link, open a newly created general Paddle portal session instead. OpenPost never stores the temporary portal URL. Plan cards show OpenPost's USD list-price estimates. Paddle shows the localized amount, discounts, and tax before checkout is confirmed. OpenPost does not present a subscription amount as final unless it has provider-backed amount data. ##### Failed-payment recovery When Paddle reports the organization subscription as `past_due`, OpenPost shows the same payment notice throughout the authenticated app and in **Settings → Organization → Plan & usage**. Paid-plan access remains restricted until a newer Paddle subscription snapshot confirms recovery. Every member can see the notice, but only an organization owner or administrator can open billing recovery. Select **Update payment method** to create a fresh Paddle customer-portal link for the exact subscription. If Paddle does not return that exact link, OpenPost opens a new general portal session. OpenPost does not store or reuse either temporary link. If you are a member, ask an organization owner or administrator to complete the update. Paddle controls retry timing and whether an unrecovered subscription is canceled. OpenPost does not show an estimated deadline. Return to OpenPost after updating the payment method; the notice clears after Paddle confirms that the subscription is active again. The **Single sign-on** tab is available for organization administration. Add an exact OIDC issuer, copy the callback and back-channel logout URLs into the provider, then test optional login before requiring SSO. Required mode checks workspace access, password recovery, and API, CLI, and MCP credentials. App tokens can be denied or limited to one workspace with current SSO assurance; required SSO does not support organization-wide app tokens. Keep an MFA-protected local instance administrator in `OPENPOST_SSO_BREAK_GLASS_EMAILS` before enforcing SSO. ##### Organization ownership The **Ownership** tab shows the current Organization Owner. Only that Owner can nominate another active Organization member. Starting a transfer requires recent authentication and the exact Organization name. The nominee receives an expiring Transactional action and can accept or decline it. The current Owner can revoke a pending transfer. The recipient action remains available to an active Organization member who has no Workspace access and does not send them through Workspace onboarding. Ownership is Organization administration, not Workspace access. The tab loads the Organizations you own and lets you select one even when you cannot open any of its Workspaces. It shows the current Owner's identity before any transfer action. Nomination does not change access. Decline, expiry, and revocation leave the current Owner in place. Acceptance changes both roles in one database transaction: the nominee becomes the only Owner and the prior Owner becomes an Organization Administrator. Creator authority moves to the nominee in the same transaction, while the Organization's subscription remains attached to it. Initiation and every terminal outcome appear in Organization audit evidence without exposing the action link or member email. Expiry is recorded by a durable Job, so it does not depend on someone opening the page. The in-app nomination notice follows the active English or Portuguese interface. The required email includes both supported languages. If OpenPost cannot load the current transfer state, the page reports the failure and hides all nomination actions until a successful refresh. Database upgrades apply migration 097 automatically. It adds ownership transfer and audit state; no operator action is required. ##### Delete an Organization permanently Only the current Organization Owner can delete it. Open **Settings → Organization → Ownership**, select any Organization you own, and choose **Delete Organization** to load a current preview. This route remains available when you do not belong to one of the Organization's Workspaces. **Settings → Workspace → General** provides the same action for the current Workspace's Organization. The preview names every owned Workspace, shows the local Paddle subscription state and counts pending provider writes, durable Jobs, and cleanup Jobs. It also explains the access that ends, the limited evidence retained, the data lost permanently, and every blocker. Resolve every subscription state other than `canceled` and wait for Paddle to confirm that terminal state. Use **Cancel pending checkout** in the deletion preview for attempts that have not produced a Paddle subscription. OpenPost then rejects any attempt to reopen that checkout and retains its opaque ID as a billing boundary. If an already-open Paddle checkout completes late, OpenPost uses that boundary to cancel the new subscription immediately, including after Organization deletion. Revoke or complete a pending ownership transfer. Publishing, provider-scheduled work, provider, and cleanup work must reach a safe terminal state. OpenPost adds no separate waiting period after these blockers are resolved. Deletion requires the exact canonical Organization name and recent password, passkey, or linked-identity authentication. The server locks the Organization and repeats the Owner, name, billing, transfer, and external-work checks in the same transaction that removes all owned Workspaces, memberships, scoped credentials, Jobs, media records, and provider-write state. A failed request rolls back the complete transaction and leaves the dialog and Organization available. Success ends affected Organization and Workspace access immediately. OpenPost keeps one content-free lifecycle record with the Organization ID and canonical name, actor, Workspace count, final local billing state, action, and time for instance-administrator audit. It also keeps required billing reconciliation evidence after a canceled checkout is resolved: the opaque checkout and Organization IDs, provider name, opaque subscription ID, cancellation time, and resolution time. OpenPost does not currently expire this content-free evidence. It does not retain deleted content, provider credentials, invitation links, or media in that record. Blob deletion runs from durable cleanup Jobs created by the successful transaction. The Organization and its Workspaces cannot be recovered. ##### Organization audit evidence The **Organization audit** tab is available only to the Organization Owner. It combines permission-safe evidence from identity and access administration, impersonation grants scoped to the Organization when created, billing checkout, MCP calls, Publication lifecycle and authorization, and provider writes. An Owner can select an Organization even when they are not a member of one of its Workspaces. Organization ownership allows the Owner to inspect this administrative evidence across the Organization; it does not grant access to a Workspace's Publications, media, messages, or other content. Filter by exact action, opaque actor ID, Workspace ID, resource type, or time. Use **Load older evidence** to continue through stable pages. JSON and CSV exports apply the same filters as the visible list. The app downloads exports through the authenticated API, including in the Android wrapper. The view and both exports include actor and effective actor IDs, action, resource, result, time, and allowlisted changed fields such as role, access state, SSO mode, or a verified Organization domain. They exclude user email addresses, authored content, tokens, invitation links, credentials, arbitrary identity detail, and raw provider responses. #### Instance Instance settings are available only to instance administrators. - **Overview** shows account growth, publishing activity, and the running release. - **Configuration** manages optional account policy, authentication, email delivery, OpenPost Image Editor, feedback, provider behavior, and OAuth provider applications. - **AI prompts** manages the base and platform-specific writing instructions used by AI social-post generation. - **Users** shows instance-wide account, plan, access, and activity details. - **Instance audit** uses the Organization audit vocabulary across every Organization. Filter by Organization, Workspace, actor, action, resource, result, or time, then export the same safe facts as JSON or CSV. The instance audit requires an unscoped instance-administrator browser session. Organization Owners, Workspace members, and API, CLI, or MCP credentials cannot open it, even when a credential belongs to an instance administrator. The projection names actor and effective actor separately and keeps success, failure, and pending results explicit. Organization and Workspace IDs provide scope without granting access to their content. The Configuration screen identifies whether each value comes from the environment, an encrypted admin override, or the application default. Instance administrators can replace an allowlisted environment-backed value. Before and after saving, the screen names the environment source and clearly labels that the admin value will override or is overriding it. Removing the admin override restores the environment value or default after a server restart. Database, encryption, network, and storage settings remain deployment-only because OpenPost needs them before this screen can load. The AI prompts screen applies changes to the next generated draft without a server restart. It keeps the source-controlled base and platform defaults visible, records the last administrator and update time, and lets an administrator restore each built-in prompt. OpenPost keeps the JSON response schema and destination validation outside the editable text, so prompt changes cannot alter the response shape required by the composer. Prompt overrides are encrypted in the database. The screen and API require an unscoped instance-administrator browser session; API, CLI, MCP, and Workspace-scoped credentials are rejected. Self-hosted users need provider apps when they bring their own OAuth keys. Provider apps keep environment-first precedence and remain read-only when defined by the deployment. Mastodon is a common case because each server can need its own app. See [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md), [Platform Overview](https://docs.openpo.st/providers/index.md), and [Mastodon](https://docs.openpo.st/providers/mastodon.md). ### Threads Source: [https://docs.openpo.st/usage/threads.md](https://docs.openpo.st/usage/threads.md) This page is for people creating ordered multi-post sequences. Threads let you publish multi-post sequences in order. #### How they work - Each reply keeps its place in the thread. - OpenPost publishes one part at a time, in order. - Each social network receives replies in the form its API requires. #### Caveats - LinkedIn sends thread parts after the first one as comments. An operator can turn this off with `LINKEDIN_DISABLE_THREAD_REPLIES`. When it is off, OpenPost marks the account as unable to publish a thread. - Failures in early posts can block later posts in the same thread. ### OpenPost Video Editor Source: [https://docs.openpo.st/usage/video-editor.md](https://docs.openpo.st/usage/video-editor.md) OpenPost Video Editor is a local-first multitrack editor for social video. Open app.openpo.st/video-editor in current Chrome or Edge, choose a folder on your computer, and create a project. Editing does not require an account and does not add a watermark. Use [Quick Cut](https://docs.openpo.st/usage/quick-cut.md) when you only need to remove ranges without changing the picture or mix. Use the [Recorder](https://docs.openpo.st/usage/recording.md) to capture a screen, camera, or microphone before editing. #### Choose a workspace folder The folder you choose is the source of truth for Video Editor work. OpenPost creates readable `projects`, `media`, `recordings`, and `exports` folders inside it. Project documents, copied media, linked-file references, thumbnails, waveforms, transcripts, proxies, render jobs, and finished exports stay on your computer. The browser stores the permission handles needed to reopen known workspace folders, small editor preferences, bounded derived-media and model caches, and temporary recovery data for active recording or export work. It may ask you to reconnect a folder after a browser restart or permission change. Back up the workspace folder like any other work folder. Clearing browser data can remove saved handles, preferences, caches, and recovery data, but it does not delete the workspace folder on disk. You can keep several known workspace folders, switch between them, or forget a handle without deleting its files. Project trash is recoverable for seven days. Portable `.openpost.zip` bundles include the project and collected media with checksums; JSON snapshots contain the project document without source bytes. #### Import and organize media Import video, audio, images, Lottie files, and supported subtitle tracks into the Media pool. A collected source is copied into the workspace. A linked source stays at its existing location and must remain available. The editor detects duplicate media, preserves technical metadata, and builds thumbnails, filmstrips, waveforms, and preview proxies in the background. Browser-undecodable ProRes sources keep a local compatibility proxy for preview. Full export still reads the original source. MKV, MKA, and WebM files can expose UTF-8, WebVTT, ASS, and SSA subtitle tracks for insertion into the timeline. #### Edit on the timeline The desktop editor has a tool rail, active panel, preview, inspector, and resizable multitrack timeline. Narrow screens use a touch timeline, bottom tool dock, and contextual sheets. You can split, trim, ripple-delete, slip, slide, roll, duplicate, freeze, reverse, group, nest, and reorder clips. In and Out points can limit playback and export. The timeline supports video, audio, image, Lottie, text, subtitle, shape, adjustment, controller, and nested composition items. Track locks, mute and solo state, snapping, markers, linked clips, multi-selection, clipboard operations, undo, redo, zoom, scrubbing, and keyboard shortcuts share one command model. Change shortcuts in Editor settings instead of relying on fixed keys. Projects can hold reusable sequences and nested compositions. Published composition controls let one composition expose selected text, color, number, toggle, and media values to each instance. #### Motion, effects, color, and audio Animate transforms, masks, effect values, text, and other supported properties with keyframes. The keyframe editor switches between Dope Sheet, Graph, and Split views in Edit and Motion. Its scoped shortcuts change views with 1, 2, and 3, add or update a key with K in Edit, move between keys with Option/Alt+[ and ], toggle auto-key with A, and fit the active view with F. You can remap or unassign every command in Editor settings. Spatial motion paths, easing presets, motion presets, parenting, links, expressions, and modifiers edit the same keyframe data. Direct canvas gestures commit once when the gesture ends so undo remains useful. Visual tools include crop, corner pinning, masks, blend modes, adjustment layers, transitions, caption and text styles, backgrounds, stickers, color wheels, curves, scopes, LUTs, and reusable grade and effect presets. Preview and export use the same effect and compositing rules. Audio tools include clip and track volume, fades, envelopes, meters, loudness normalization, ducking, channel routing, equalization, noise reduction, speed and pitch controls, compressor, pan, reverb, delay, chorus, flanger, and distortion. You can record a synced voiceover while the timeline plays. #### Transcribe and edit by text Transcription runs on your device. Parakeet is the default on supported WebGPU devices; Whisper Tiny, Base, Small, and Large v3 Turbo are also available. The selected model downloads on first use and remains in the browser cache until you clear it in **Models** or clear site data. Correcting a word updates the timed transcript and linked caption cue. Select timed words to remove the matching media range as one undoable ripple edit. Silence and filler tools show proposed ranges and the total time removed before they change the timeline. Quiet-section detection measures level; speech-pause detection uses the local speech model. Caption tools can create editable caption clips, consolidate them into subtitle items, apply style presets, burn captions into video, export SRT or VTT sidecars, or add a soft subtitle track where the chosen container supports it. #### Local generation and analysis Optional local tools cover scene detection, semantic and visual search, smart framing, focus zoom suggestions, image generation, frame interpolation, upscaling, music generation, and text to speech. Each tool shows its model and download state. Large models are downloaded only when you start the related tool and can be removed from **Models**. Generated media is committed to the workspace before it is added to the timeline. #### Save, recover, and share projects Edits autosave to the project document in the selected workspace. Version history, named checkpoints, restore points, project duplication, trash recovery, snapshots, and portable bundles are local. Video Editor does not create or sync cloud projects. If another tab changes the same project, the editor protects the newer disk revision instead of overwriting it. Reopen the project or save your current work as a copy. Atomic writes and temporary-file cleanup protect project documents from interrupted saves. #### Export The export dialog validates the selected range, visible or audible content, missing sources, codec support, subtitles, expected render path, duration, and estimated size before rendering. Formats include MP4, MOV, WebM, MKV, audio-only output, and PNG, JPEG, or WebP image sequences. Available video codecs depend on the container and browser. An eligible unmodified source range uses packet copy, preserving encoded video and audio without a decode and re-encode cycle. Other projects use the full compositor and audio mixer. Export always reads original sources at the chosen output resolution. Long or large outputs stream through browser-managed scratch storage when needed and are committed to the workspace only after success. Add one job, marker ranges, or fixed-duration ranges to the render queue. Jobs freeze their timeline and export settings when queued. The project queue renders one job at a time, survives reloads, resumes runnable pending work, and lets you pause, reorder, cancel, retry, or clear jobs. Finished files and image-sequence folders remain available in **Exports** until you delete them. #### Send an export to OpenPost **Send to OpenPost** uploads the selected finished export to the current OpenPost Workspace Media library. After upload, **Open composer** opens a new composer with that exact media item attached. Video Editor does not upload the project, sources, proxies, transcripts, or other exports. Publishing and scheduling stay in the composer. If you are signed out or have no OpenPost Workspace selected, download the export or sign in before sending. #### Browser and device limits - Current desktop Chrome and Edge are the supported target because the full editor needs the File System Access API, WebCodecs, WebGL2, workers, and origin-private storage. - Codec, WebGPU, screen-capture, hardware-acceleration, and file-streaming support varies by browser, operating system, and device. The editor probes the selected operation and explains an unavailable option. - Local models can require hundreds of megabytes or more. The Models panel shows stored size and provides removal controls. - Keep the tab open while a render or local model job is active. Saved projects and queued work remain in the workspace if the page closes. - Editing and rendering are local. OpenPost does not provide server rendering, cloud project sync, or a server transcription fallback. ### Workspaces Source: [https://docs.openpo.st/usage/workspaces.md](https://docs.openpo.st/usage/workspaces.md) Use a workspace to keep one brand, client, or project separate. #### What belongs to a workspace - Connected social accounts - Uploaded media - Posts and threads - Prompts and scheduling settings - Team members and pending invitations #### Why they matter They keep brands, teams, and personal work from sharing social account keys or media by mistake. #### Manage workspace access Workspace admins manage accepted members and invitations from **Settings → Workspace → Members**. Other active members can review the team, but OpenPost hides the invite form and access-changing actions from them. Workspace roles have separate permissions: - **Admin** can manage workspace access and settings, and can create or change workspace content. - **Editor** can create and change workspace content, but cannot manage access. - **Viewer** has read-only workspace access. Use the team search and role or status filters to find accepted members and any invitation. Invitation state shows Created, Queued, Sent, Delivered, Delivery failed, Copy link only, Expired, Revoked, or Accepted. **Sent** means the email provider accepted the request. Only an authenticated delivery callback can mark it **Delivered**. ##### Invite someone 1. Open **Settings → Workspace → Members**. 2. Enter the collaborator email and role. 3. Check the invitation email state. OpenPost queues one expiring Transactional email whether or not the recipient already has an account. 4. Copy the one-time invite link as a fallback while it is visible. Invitation email includes the Workspace, inviter, role, expiry, and acceptance link. Optional notification preferences do not suppress access email. The configured email provider and Workspace invitation controls still apply. If email is unavailable or cannot be queued, Settings keeps the pending invitation and its reserved seat, shows the failure, and leaves the copy link available. Resend rotates the secret and queues a new delivery without creating another active invitation. OpenPost permits one resend per minute and five per hour for each invitation and administrator. A limited response includes the exact UTC time when the administrator can try again. Concurrent resends use one atomic generation change, so only one request can replace the current link. The invited user must sign in with the invited email address before accepting the link. Accepted invites add the user to the workspace immediately and open that Workspace without sending existing users through plan selection or checkout. Viewers receive invitation orientation without destination-connection or publishing setup actions. Pending invitations count toward team seats until they are accepted, revoked, or expire. An admin can resend an invitation to rotate its secret and extend its expiry, or revoke it to free the reserved seat. Only the newest link returned by a resend can be accepted. Expired, revoked, unknown, and wrong-email links return the same safe acceptance error. This avoids revealing whether an email address or invitation exists. OpenPost stores only the invitation token hash on the invitation. The raw token is limited to the acceptance link returned once. Durable email jobs encrypt that link with the application encryption key and decrypt it only in the delivery worker. The token is not returned as a separate API field and is excluded from the team list, access history, logs, and telemetry. ##### Change accepted access An admin can change an accepted member's role, deactivate access temporarily, restore an inactive member, or remove the member permanently. Inactive members cannot open the workspace and do not consume a team seat. Restoring one reserves a seat again, so OpenPost rechecks the current plan limit before the change. OpenPost never allows a role change, deactivation, or removal to leave a workspace without an active admin. This safeguard also applies when an admin changes or removes their own access. Use deactivation when access may be needed again; removal deletes the workspace membership instead. The access history records invitation, role, status, and removal changes. Only active workspace admins can view it. #### Delete a Workspace permanently Only the Organization Owner can delete a Workspace. Open **Settings → Workspace → General** and choose **Delete workspace** to inspect the current deletion preview. The preview lists the content and access that will be removed, the billing, audit, tax, or legal records that may remain, whether recovery is possible, and every blocker that must be resolved first. Deletion is permanent. OpenPost requires the exact current Workspace name and a recent password, passkey, or linked sign-in check. The server checks ownership before deletion, then repeats the name and blocker checks inside the deletion transaction. Accepting a browser confirmation by itself never deletes data. An Organization must keep at least one Workspace. An active billing subscription assigned to the Workspace, an unfinished provider write, or an active cleanup job also blocks deletion. Follow the blocker text to create another Workspace, move or cancel billing, or wait for the pending work to reach a safe outcome. If deletion fails, the dialog keeps the entered name and the current Workspace selected, and OpenPost does not remove its data. After successful deletion, OpenPost records Organization-scoped audit evidence with the Workspace ID, canonical name, actor, and time. That evidence remains available to the Organization Owner even though the Workspace and its content cannot be recovered. ## Providers ### Bluesky Source: [https://docs.openpo.st/providers/bluesky.md](https://docs.openpo.st/providers/bluesky.md) This page is for users connecting a Bluesky account. Bluesky is the simplest provider to enable. #### What you need - A Bluesky account handle - A Bluesky app password #### Setup 1. In Bluesky, open Settings. 2. Create an app password. 3. Connect the account in OpenPost using the handle and app password. #### Analytics Analytics is an optional feature per connected Bluesky account. It starts off for a new account. Enable it after connection or in Account details. OpenPost collects follower, following, and post totals plus published-post likes, replies, reposts, and quotes when enabled. These counters come from Bluesky's public AppView endpoints and do not require another account permission. Disabling Analytics stops future Bluesky analytics collection without deleting stored metrics or revoking the app password. #### Comments and inbox Direct messages and Comments and replies are separate optional features per connected Bluesky account. Each starts off for a new account. Enable them after connection or in Account details. - Comments and replies: OpenPost can list replies, send replies, and delete replies posted by the connected account when enabled. Disabling it stops future Bluesky comment collection and reply actions without deleting stored replies or revoking the app password. - Direct messages: OpenPost can collect and send Bluesky chat messages when enabled. Chat needs an app-password connection. Disabling it stops future message collection and sending without deleting stored messages or revoking the app password. Availability for each feature depends on provider support, required scopes, and plan access as distinct facts. #### Grow Grow is an optional feature per connected Bluesky account. It starts off for a new account. Enable it after connection or in Account details to discover candidates and follow them through OpenPost. Disabling Grow stops future discovery and follow checks without deleting stored recommendations or revoking the app password. OpenPost never follows automatically, each follow remains an explicit action and requires Grow to stay enabled. #### Notes - No server-side OAuth app is required. - OpenPost publishes images through `app.bsky.embed.images` and videos through `app.bsky.embed.video`. - Bluesky video support is limited to one video attachment per post in OpenPost. ### Discord Webhooks Source: [https://docs.openpo.st/providers/discord.md](https://docs.openpo.st/providers/discord.md) This page is for operators configuring Discord and users connecting a channel webhook. Discord uses an incoming webhook link instead of OAuth. In **Accounts**, choose **Discord**, paste the link, and connect it. OpenPost checks the webhook and encrypts the full link before saving it. Create a webhook in the target Discord channel under **Integrations → Webhooks**. Treat its URL like a password. Anyone who has it can post to that channel. #### Publishing OpenPost supports: - text messages; - up to 10 file attachments; - attachment descriptions from media alt text; - reply references for text-and-thread segments; - scheduled publishing and deletion of messages created by the webhook. OpenPost sends the text and files together and saves the Discord message ID. User and role mentions are off by default, so a scheduled post does not send an unexpected alert. OpenPost only accepts HTTPS webhook links on Discord domains. It rejects unsafe or fake links. #### Optional features Discord webhooks do not support Direct messages, Comments and replies, Analytics, or Grow. OpenPost only publishes through the webhook. These per-account optional features remain unavailable for Discord even when enabled elsewhere. #### Limits Discord sets the upload limit for the server or account that owns the webhook. OpenPost checks the file count and uses a safe 10 MiB limit, but Discord can enforce a different limit. Discord webhooks can only send. They do not let OpenPost read a channel inbox, personal alerts, or direct messages. #### Discord bot mode Discord bot mode is a separate connection contract and is **not publicly available in OpenPost**. Repository paths for guild installation, permitted-channel targeting, publishing, and bounded analytics remain behind independent readiness gates. A configured application, bot token, fixture test, or working webhook does not certify bot mode. Do not advertise or enable public Discord bot connections or operations until the exact application, guild, channel, permissions, runtime controls, and current live evidence pass the [Provider Readiness and Launch Gate](https://docs.openpo.st/operations/provider-launch-matrix.md). Incoming webhooks remain supported independently and do not inherit bot readiness. ### Facebook Source: [https://docs.openpo.st/providers/facebook.md](https://docs.openpo.st/providers/facebook.md) This page is for operators configuring Facebook and users connecting a Page. Facebook Pages uses Meta OAuth, asks the user to choose a Page, and stores the selected Page access token. #### Requirements - Meta developer app with Facebook Login configured - OAuth redirect URL: ```text https://your-domain.com/api/v1/accounts/facebook/callback ``` - App permissions: - `business_management` - `pages_show_list` - `pages_read_engagement` - `pages_manage_engagement` - `pages_manage_posts` - `pages_messaging` - Public `OPENPOST_MEDIA_URL` or S3/R2 public media URL for image and video posts #### Configuration Configure Facebook through the provider app registry. For bootstrap/self-hosting, use `OPENPOST_PROVIDER_APPS`: ```json [ { "provider": "facebook", "client_id": "your-meta-app-id", "client_secret": "your-meta-app-secret" } ] ``` If `redirect_uri` is omitted, OpenPost derives it from `OPENPOST_APP_URL`. #### Current Scope - Connects a selected Facebook Page. - Publishes text-only Page feed posts. - Publishes one image URL through the Page photos endpoint. - Publishes 2-10 images as an unpublished-photo set attached to one Page feed post. - Publishes one video URL through the Page videos endpoint. - Publishes one image or video as a Page Story when the Story profile is selected. - Lists, replies to, hides, and deletes eligible Page comments when Comments and replies is enabled for that Page. - Supports Page inbox collection for Direct messages when enabled and replies within Meta's reply window. Direct messages and Comments and replies are separate per-account choices that start off. - Grow is not available for Facebook. - Supports scheduling and platform variants through the normal OpenPost post flow. Direct messages, Comments and replies, and Analytics are optional and per connected Page. Disabling a feature stops future provider reads and writes without deleting history or revoking provider authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. #### Current Limits - Multi-photo publishing creates a feed post, not a persistent Page album. - Comment actions depend on Meta's permission for that Page and comment. - Media URLs must be public HTTPS URLs. - Live-account verification is still recommended before relying on production Page publishing. #### Analytics Analytics is an optional feature per connected Facebook Page. It starts off for a new account. Enable it after connection or in Account details. OpenPost collects Page follower totals and published-post reactions, comments, and shares when enabled. It uses `pages_read_engagement`, keeps missing counters distinct from measured zero, and does not use deprecated Page impression metrics. Disabling Analytics stops future Facebook analytics collection without deleting stored metrics or revoking authorization. #### Troubleshooting - `facebook account has no manageable pages` usually means the authenticated user has no eligible Pages, lacks full control of the Page, or the app lacks `business_management` or `pages_show_list`. Business Portfolio Pages require `business_management` for discovery. - Media publish failures usually mean the media URL is not public HTTPS or Meta cannot fetch it. - Permission errors usually require Meta app review for the Pages permissions above. ### Providers Source: [https://docs.openpo.st/providers/index.md](https://docs.openpo.st/providers/index.md) OAuth and social app setup cause many connection errors. Use this guide as you turn on each network. #### Current social network connections The table below is an implementation and setup inventory. It is not a Hosted service readiness claim. The Accounts page reads `GET /api/v1/accounts/providers`, which now uses the same evidence-based projection as scheduling and the publisher instead of treating adapter registration as availability. | Network | Sign-in method | Server setup | Status | Notes | | --------- | ---------------------- | ----------------------------------------------- | ------------ | -------------------------------------------------------------------------- | | Bluesky | App password | None | Built-in | Users connect with handle + app password. | | X | OAuth 1.0a | Client ID + secret | Configurable | Requires an X developer app with OAuth 1.0a user auth enabled. | | Mastodon | OAuth 2.0 per instance | Dynamic registration or `MASTODON_SERVERS` JSON | Configurable | One app per instance, unless dynamic registration is enabled. | | LinkedIn | OAuth 2.0 | Client ID + secret | Configurable | Replies may need extra approval. | | Threads | Meta OAuth | Client ID + secret + redirect URI | Configurable | Public media URL required. | | Facebook | Meta OAuth | Provider app registry | Configurable | Pages only; public HTTPS media required. | | Instagram | Meta OAuth | Provider app registry | Configurable | Business or Creator account; public media URL required. | | TikTok | OAuth 2.0 | Provider app registry | Configurable | Video and photo paths; provider approval and public media required. | | YouTube | Google OAuth | Provider app registry | Configurable | One-video upload with configurable privacy; live verification recommended. | | Pinterest | OAuth 2.0 | Standard-access provider app | Unavailable | Development and certification paths only; no public Hosted claim. | | Telegram | Instance-owned bot | Bot token, username, and webhook secret | Unavailable | Bot mode stays gated until current live certification. | | Discord | Incoming webhook | None | Built-in | Users connect a webhook URL; text and streamed attachments are supported. | Start with one network. Check that its callback works before you add another. A configured app may still need approval, a valid account grant, current local and live proof, an allowed policy mode, and an enabled runtime control. Working code and app keys do not prove that a real account can publish each post type. Use the [Provider Readiness and Launch Gate](https://docs.openpo.st/operations/provider-launch-matrix.md) to inspect the mechanically enforced evidence model. No posting option has passed our final live check on OpenPost Hosted yet. A social app can appear in OpenPost before it is ready for real accounts. Social app keys can come from older environment variables, `OPENPOST_PROVIDER_APPS` JSON, or encrypted rows managed in **Settings → Instance → Configuration → Provider apps**. Environment-defined apps are read-only in the interface and win over matching database rows. Matching database fallbacks remain visible and can be deleted while the environment app stays active. Mastodon often needs this setup because each server can need its own app. People can still connect a public Mastodon server from Accounts when that server allows automatic app setup. For other OAuth networks, use these settings when you want to supply your own app keys. OpenPost loads the effective registry at startup. Restart OpenPost after you change a provider app. If connection or publishing fails, use [Provider Troubleshooting](https://docs.openpo.st/providers/troubleshooting.md) to collect diagnostics and map common OAuth, permission, media URL, and publishing errors to the right fix. #### Support matrix This matrix reflects implemented OpenPost code paths, not the full theoretical capability of each provider API and not current Hosted service certification. | Platform | Text posts | Image posts | Threads / replies | Scheduled posts | Video posts | Account versions | Analytics | | --------- | ---------- | ----------- | --------------------------- | --------------- | --------------------------------------------------------------- | ---------------- | ---------------------------------------------------- | | X | Yes | Yes | Yes | Yes | Yes; account tier and API limits set the rules | Yes | Account and post numbers | | Mastodon | Yes | Yes | Yes | Yes | Yes; the server sets its limits | Yes | Account and post numbers | | Bluesky | Yes | Yes | Yes | Yes | One MP4 up to 100 MB | Yes | Account and post numbers | | LinkedIn | Yes | Yes | Child posts become comments | Yes | One MP4 from 3 seconds to 30 minutes, up to 500 MiB | Yes | Profiles and Organization Pages with approved access | | Threads | Yes | Yes | Yes | Yes | One video or a mixed carousel; public media links are required | Yes | Requires insights permission | | Facebook | Yes | Yes | Page comment replies | Yes | Public video links, Stories, Reels, and long videos | Yes | Page and post numbers | | Instagram | No | Yes | Comment replies | Yes | Reels, Stories, and carousels; public media links are required | Yes | Requires insights permission | | TikTok | No | Yes | No | Yes | One video or 1–35 photos; app review may be required | Yes | Requires stats and video-list permissions | | YouTube | No | No | Comment replies | Yes | One Short or video with title, description, and privacy options | Yes | Channel and video numbers | | Pinterest | Gated | Gated | No | Gated | Gated; no public Hosted claim | Gated | Gated; no public Hosted claim | | Telegram | Gated | Gated | No | Gated | Gated; no public Hosted claim | Gated | Gated; no public Hosted claim | | Discord | Yes | Yes | Reply links between parts | Yes | Yes, within the webhook's file-size limit | Yes | No analytics | Direct messages, Comments and replies, Analytics, and Grow are optional and per connected account. Each feature starts off for a newly connected account. Enable supported features after connection or in Account details. Disabling a feature stops future provider reads and writes without deleting stored history or revoking provider authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. Grow never follows automatically. Existing accounts keep their current behavior after upgrade. Use each provider page to see which optional features that provider supports. Grow discovery and follow are available only for Bluesky and Mastodon. Inbox Direct messages are available for X, Bluesky, Facebook Pages, Instagram Professional accounts, and Mastodon. Engagement Comments and replies are available for X, Mastodon, Bluesky, LinkedIn, Threads, Facebook Pages, Instagram, and YouTube. Analytics is available for X, Mastodon, Bluesky, LinkedIn, Threads, Facebook, Instagram, TikTok, and YouTube. Discord webhooks support none of these optional features. Pinterest, Telegram bot mode, and Discord bot mode remain excluded from public availability statements until their exact operations have current live certification. #### Implementation limits Use this table when checking implemented formats and safe provider limits. An implementation is not a Hosted service certification claim. App review, the right account type, public media links, runtime controls, and current local and live tests remain separate gates. Limits were reviewed against official provider documentation on 2026-08-03. Connected-account or instance limits override these safe defaults where OpenPost can resolve them. | Provider | Text implementation | Image implementation | Video implementation | Threading implementation | Scheduling | Variants | | ---------------- | ----------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ----------- | ----------- | | X | 280 standard or 25,000 subscribed weighted characters | Up to 4 images | One video; 140 seconds/512 MiB standard or 4 hours/16 GiB subscribed; real-account verification still required | Replies | Implemented | Implemented | | Mastodon | Implemented | Up to 4 attachments | MP4, MOV, or WebM under a safe 99 MiB default; connected instances can advertise their actual formats and size limit | Replies | Implemented | Implemented | | Bluesky | Implemented | Up to 4 images | One MP4 video via `app.bsky.video.*`; real-account verification still required | AT Protocol reply refs | Implemented | Implemented | | LinkedIn | Implemented | One JPG/PNG/GIF image, 2-20 images, or a document | One MP4, 3 seconds to 30 minutes and up to 500 MiB; live-account verification is still recommended | Thread children are posted as comments | Implemented | Implemented | | Threads | Implemented | One image or a 2-20 item carousel | One video or mixed carousel with public HTTPS media | `reply_to_id` | Implemented | Implemented | | Facebook | Implemented | One image or a 2-10 image multi-photo post | One public HTTPS video URL; Story publishing accepts exactly one image or video | Comment replies | Implemented | Implemented | | Instagram | No text-only path | Single image and carousel paths | Reels for Business and Creator accounts; live-account verification is still recommended | Comment replies and Story paths for implemented settings | Implemented | Implemented | | TikTok | No text-only path | 1-35 JPEG/WebP photos, up to 20 MB each | One MP4 or MOV up to 10 minutes and 4 GB through Direct Post or inbox/upload; app review still applies | No threading path | Implemented | Implemented | | YouTube | No text-only path | Thumbnail only | Short and Video uploads with privacy, category, title, description, and resumable upload | Comment replies and moderation | Implemented | Implemented | | Pinterest | Certification-only code path | One to five images | One resumable MP4 video; Standard access and current live certification are required | No threading path | Gated | Gated | | Telegram bot | Certification-only code path | Photos, documents, and media groups | Video and media groups through the instance bot; every operation remains publicly gated | Visible ordered caption-overflow follow-up | Gated | Gated | | Discord Webhooks | Implemented | Up to 10 file attachments | MP4, MOV, or WebM; OpenPost uses Discord's safe 10 MiB default because the actual limit can vary | Reply references between segments | Implemented | Implemented | #### Known limitations - **Video support is uneven:** implementation exists across multiple providers, but support is still provider-dependent and some paths need end-to-end verification with real accounts. - **TikTok and YouTube have provider gates:** TikTok Direct Post requires app audit approval, while unaudited Google projects can force YouTube uploads to private. Verify each production account and format before relying on either integration. - **OpenPost hides actions an account cannot use:** the app explains when an account, permission, review, partner, or platform search requirement blocks a control. - **X limits are resolved per account:** Basic, Premium, and Premium+ accounts use subscribed text and video limits when X verifies the tier. Unknown or stale tiers use standard limits. - **Social APIs can change:** platforms may change their APIs, limits, or app review rules at any time. - **OAuth tokens require HTTPS:** callbacks need a valid domain with TLS for OAuth to work. A platform can offer a feature while OpenPost still marks its implementation missing or untested. "Implemented" means the code path exists in OpenPost. "Verified" means the implementation has been confirmed against a live provider account recently. Deployment details still matter. Threads, Facebook, Instagram, and TikTok direct-post flows depend on public media URLs, and LinkedIn depends heavily on granted app permissions. #### Notes for each network - **X:** Requires an X developer app with OAuth 1.0a user auth enabled and matching callback URLs. - **Mastodon:** Setup is per instance. Custom public instances can be entered from Accounts; operator-pinned instances can use `MASTODON_SERVERS`, `OPENPOST_PROVIDER_APPS`, or the instance-admin provider app API. - **Bluesky:** Uses handle plus app password. No server-side OAuth app is required. - **LinkedIn:** Permissions and app review can block some posts or replies even when OpenPost supports them. - **Threads:** Media must be reachable at a public `OPENPOST_MEDIA_URL`, and Meta fetches those files server-side. - **Facebook:** Use provider key `facebook`. OpenPost connects a selected Page and supports text, one image or video, photo posts with 2–10 images, Stories, and Page comment replies. Media must use public HTTPS links. - **Instagram:** Use provider key `instagram`. OpenPost connects a selected Business or Creator account linked to a Facebook Page. It supports one image, carousels, Stories, Reels, and comment replies. Account access and live tests still apply. - **TikTok:** Use provider key `tiktok`. OpenPost supports direct and inbox video uploads plus photo posts through public HTTPS media links. App review and live tests still apply. - **YouTube:** Use provider key `youtube`. OpenPost connects a selected channel, uploads one video with privacy, title, description, thumbnail, and playlist settings, and supports comment replies and moderation. Test a live channel before you rely on it. - **Pinterest:** The adapter remains publicly unavailable until Standard access and current live certification exist. See the [Pinterest boundary](https://docs.openpo.st/providers/pinterest.md). - **Telegram:** Bot mode remains publicly unavailable until current live certification exists for each operation. See the [Telegram bot boundary](https://docs.openpo.st/providers/telegram.md). - **Discord:** Connect an incoming webhook URL directly. OpenPost streams attachments and uses a safe 10 MiB file limit because Discord's actual limit can vary by server and account. Discord bot mode is separate and remains publicly unavailable without current live certification. Social network API rules, access, request limits, and app review can change. Check that network's docs if a feature stops working. See [Analytics](https://docs.openpo.st/usage/analytics.md) for collection timing, metric definitions, reconnect requirements, and provider-specific coverage. See [Accounts](https://docs.openpo.st/usage/accounts.md), [Engagement, Inbox, and Notifications](https://docs.openpo.st/usage/communications.md), and [Grow](https://docs.openpo.st/usage/grow.md) for the per-account optional feature model. Native auto reposts are currently available for X, Mastodon, Bluesky, and LinkedIn. OpenPost reposts only within the source network; it never turns a repost into a copied post on another network. See [Auto Reposts](https://docs.openpo.st/usage/auto-reposts.md) for rule and account-access details. ### Instagram Source: [https://docs.openpo.st/providers/instagram.md](https://docs.openpo.st/providers/instagram.md) This page is for operators configuring Instagram and users connecting a professional account. Instagram professional publishing uses Meta OAuth, asks the user to choose a Business or Creator account connected to a Facebook Page, and stores the selected Page access token. #### Requirements - Meta developer app with Facebook Login configured - Instagram Business or Creator account connected to a Facebook Page - OAuth redirect URL: ```text https://your-domain.com/api/v1/accounts/instagram/callback ``` - App permissions: - `business_management` - `instagram_basic` - `instagram_content_publish` - `instagram_manage_comments` - `instagram_manage_messages` - `instagram_manage_insights` - `pages_show_list` - `pages_read_engagement` - Public `OPENPOST_MEDIA_URL` or S3/R2 public media URL for image and Reel video posts #### Configuration Configure Instagram through the provider app registry. For bootstrap/self-hosting, use `OPENPOST_PROVIDER_APPS`: ```json [ { "provider": "instagram", "client_id": "your-meta-app-id", "client_secret": "your-meta-app-secret" } ] ``` If `redirect_uri` is omitted, OpenPost derives it from `OPENPOST_APP_URL`. #### Current Scope - Connects a selected Instagram Business or Creator account behind a Facebook Page. - Publishes a single image URL with a caption. - Publishes a single video URL as a Reel. - Lets you choose a Reel cover frame or upload a separate cover image from destination settings. - Publishes 2-10 media items as a carousel when multiple compatible media items are attached. - Publishes Stories and lists, replies to, hides, and deletes eligible comments when Comments and replies is enabled for that account. - Supports inbox collection for Direct messages when enabled and replies within Meta's reply window. Direct messages and Comments and replies are separate per-account choices that start off. - Grow is not available for Instagram. - Supports scheduling and platform variants through the normal OpenPost post flow. Direct messages, Comments and replies, and Analytics are optional and per connected account. Disabling a feature stops future provider reads and writes without deleting history or revoking provider authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. #### Current Limits - No text-only Instagram posts. - Media URLs must be public HTTPS URLs. - Account discovery currently uses Pages returned by the authenticated Meta user. - Live-account verification is still recommended before relying on production Instagram publishing. #### Analytics Analytics is an optional feature per connected Instagram account. It starts off for a new account. Enable it after connection or in Account details. OpenPost collects account followers and media count plus post likes, comments, and the views, reach, saves, and shares that Meta returns for the media type when enabled. Reconnect accounts created before `instagram_manage_insights` was added. Disabling Analytics stops future Instagram analytics collection without deleting stored metrics or revoking authorization. #### Troubleshooting - `facebook account has no connected instagram business accounts` usually means the Meta user has no eligible Pages with connected Instagram professional accounts, lacks full control of the Page, or the app lacks the required scopes. Business Portfolio Pages require `business_management` for discovery. - Media publish failures usually mean the media URL is not public HTTPS or Meta cannot fetch it. - Permission errors usually require Meta app review for the Instagram and Page permissions above. ### LinkedIn Source: [https://docs.openpo.st/providers/linkedin.md](https://docs.openpo.st/providers/linkedin.md) This page is for operators configuring LinkedIn and users connecting a member profile or Organization Page. LinkedIn uses OAuth 2.0 and has more approval friction than most other providers. #### What you need - LinkedIn developer app - `LINKEDIN_CLIENT_ID` - `LINKEDIN_CLIENT_SECRET` - Callback URL: `https://your-domain.com/api/v1/accounts/linkedin/callback` #### Personal profiles and Organization Pages OpenPost always offers the member profile returned by LinkedIn OpenID Connect. Organization Pages are an explicit operator opt-in because LinkedIn gates their permissions behind approved products: ```sh OPENPOST_DISABLE_LINKEDIN_THREAD_REPLIES=false OPENPOST_LINKEDIN_ORGANIZATIONS_ENABLED=true ``` If your LinkedIn app cannot obtain the permissions required for comment-style replies, set `OPENPOST_DISABLE_LINKEDIN_THREAD_REPLIES=true`. The legacy `LINKEDIN_DISABLE_THREAD_REPLIES` alias remains supported. With organizations enabled, the app must be approved for `rw_organization_admin`, `w_organization_social`, and `r_organization_social`. OpenPost lists only Pages for which the member has an approved `ADMINISTRATOR` role. The callback lets the user select their personal profile and several Pages. Each selection becomes a separate OpenPost account, while the shared OAuth token remains encrypted at rest. Leave `OPENPOST_LINKEDIN_ORGANIZATIONS_ENABLED=false` when the app lacks those products. Personal publishing remains available. If organization discovery fails, OpenPost keeps the personal choice instead of failing the whole connection. OpenPost defaults `LINKEDIN_API_VERSION` to the previous calendar month to avoid requesting a version LinkedIn has not activated yet. Set it only when your LinkedIn application requires an explicit supported version. #### Threading caveat LinkedIn thread child posts are implemented as comments on the first post rather than native threaded posts. Comments and replies is an optional feature per connected LinkedIn account. It starts off for a new account. Enable it after connection or in Account details. OpenPost can list comments, send replies, and delete comments where LinkedIn grants access when enabled. LinkedIn does not support Direct messages in OpenPost, and Grow is not available for LinkedIn. Disabling Comments and replies stops future LinkedIn comment collection and reply actions without deleting stored replies or revoking authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. #### Media caveat OpenPost uses LinkedIn's Images API for images and Videos API for videos. Video upload initializes with `fileSizeBytes`, uploads all returned byte ranges, and finalizes the upload before creating the post. #### Analytics Analytics is an optional feature per connected LinkedIn account. It starts off for a new account. Enable it after connection or in Account details. LinkedIn analytics require Community Management API access, so the operator must turn on Organization support and use an approved app. With that access and when enabled: - personal profiles can return followers plus post impressions, reach, reactions, comments, reposts, saves, and link clicks; - Organization Pages can return followers plus the post numbers that LinkedIn provides. The connected account also needs `r_member_profileAnalytics` and `r_member_postAnalytics` for profile analytics or `rw_organization_admin` for Organization Page analytics. Disabling Analytics stops future LinkedIn analytics collection without deleting stored metrics or revoking authorization. #### Common issues - Insufficient app approval for social actions - Organization mode enabled before the app has approved organization products - The LinkedIn member is not an approved Page administrator - Callback URL mismatch - Reply permissions missing for thread child posts ### Mastodon Source: [https://docs.openpo.st/providers/mastodon.md](https://docs.openpo.st/providers/mastodon.md) This page is for operators configuring Mastodon and users connecting a custom or preconfigured instance. Mastodon can be connected in two ways: - **Custom instance from the Accounts screen:** enter a public instance such as `mastodon.social`. OpenPost registers an app with that instance, encrypts the client secret, and reuses it for later connections. - **Preconfigured instances:** operators can still pin known instance app credentials with `MASTODON_SERVERS`, `OPENPOST_PROVIDER_APPS`, or the instance-admin provider app API. #### What you need - For custom instances: no static env entry is required, but the instance must be public HTTPS and allow app registration. - For preconfigured instances: one Mastodon app per instance and either `MASTODON_SERVERS` JSON, `OPENPOST_PROVIDER_APPS`, or an encrypted provider app row created through the instance-admin API. - Redirect URI: `urn:ietf:wg:oauth:2.0:oob` by default. #### Custom instance flow 1. Open **Accounts**. 2. Choose **Mastodon / Custom instance**. 3. Enter an instance host or URL. 4. Authorize OpenPost on that instance. 5. Paste the authorization code into the OpenPost callback page. OpenPost rejects custom Mastodon hosts that resolve to private, loopback, link-local, multicast, or otherwise local addresses. #### Example preconfigured instance ```sh MASTODON_SERVERS='[ { "name": "Personal", "client_id": "xxx", "client_secret": "yyy", "instance_url": "https://mastodon.social" } ]' ``` #### Provider app API Instance admins can store encrypted Mastodon provider apps in **Settings → Instance → Configuration → Provider apps** or through `POST /api/v1/admin/provider-apps`. Use this when a specific instance does not allow dynamic app registration or when you want OpenPost to use credentials you already created on that instance. `MASTODON_SERVERS` and `OPENPOST_PROVIDER_APPS` remain the environment-based deployment options and take precedence over matching database rows. #### Multiple instances ```sh MASTODON_SERVERS='[ { "name": "Personal", "client_id": "abc", "client_secret": "def", "instance_url": "https://mastodon.social" }, { "name": "Work", "client_id": "ghi", "client_secret": "jkl", "instance_url": "https://fosstodon.org" } ]' ``` #### Analytics Analytics is an optional feature per connected Mastodon account. It starts off for a new account. Enable it after connection or in Account details. OpenPost collects follower, following, and post totals from the connected instance and favourites, replies, and reblogs for published posts and thread segments when enabled. Instance software and policy determine which counters are returned. Disabling Analytics stops future Mastodon analytics collection without deleting stored metrics or revoking authorization. #### Comments and inbox Direct messages and Comments and replies are separate optional features per connected Mastodon account. Each starts off for a new account. Enable them after connection or in Account details. - Comments and replies: OpenPost can list replies, send replies, favourite or unfavourite replies, and delete replies posted by the connected account when enabled. Disabling it stops future Mastodon comment collection and reply actions without deleting stored replies or revoking authorization. - Direct messages: OpenPost can collect direct-visibility posts when enabled. Mastodon direct posts are not end-to-end encrypted; mentioned accounts and involved servers can read them. Disabling it stops future message collection without deleting stored messages or revoking authorization. Availability for each feature depends on provider support, required scopes, and plan access as distinct facts. #### Grow Grow is an optional feature per connected Mastodon account. It starts off for a new account. Enable it after connection or in Account details to discover candidates and follow them through OpenPost. Disabling Grow stops future discovery and follow checks without deleting stored recommendations or revoking authorization. OpenPost never follows automatically, each follow remains an explicit action and requires Grow to stay enabled. #### Notes - The current backend config default for `MASTODON_REDIRECT_URI` is `urn:ietf:wg:oauth:2.0:oob`. - OpenPost may show the config `name` in the UI, but the persisted provider identity is the full `instance_url`. - The stored `instance_url` needs to stay consistent with the configured provider entry. ### Pinterest Source: [https://docs.openpo.st/providers/pinterest.md](https://docs.openpo.st/providers/pinterest.md) This page is for operators reviewing Pinterest's certification boundary. Pinterest is **not publicly available in OpenPost**. The repository contains adapter and contract paths for controlled development and certification, but an implementation, configured credential, or mocked test is not a Hosted service availability claim. Do not advertise or enable public Pinterest connection, publishing, discovery, or analytics operations unless the exact app, account, scopes, output profile, policy mode, runtime controls, and current live evidence pass the [Provider Readiness and Launch Gate](https://docs.openpo.st/operations/provider-launch-matrix.md). #### Required readiness Production use requires Pinterest Standard API access. Trial access is limited to development and certification work and must not be treated as production approval. Each intended operation must remain fail-closed until its own evidence is current: - OAuth connection and refresh; - board and optional board-section targeting; - image or video Pin publishing; - account-content discovery; - account and Pin analytics. #### Implemented contract inventory The controlled certification paths include OAuth token refresh and revocation, board targeting, bounded Pin discovery, analytics measurement, image Pins, and resumable video processing. These facts describe repository code only; they do not make Pinterest selectable for public Hosted accounts. Pinterest content requires a board. A board section is optional and must belong to the selected board. Implemented validation accepts one to five JPEG, PNG, or WebP images, or one MP4 video with its required cover and metadata. Pin titles and descriptions use separate provider limits. #### Operator boundary Keep Pinterest readiness controls disabled for normal production traffic until current live certification exists. Store OAuth credentials through the encrypted provider-app boundary, never in workspace data, logs, jobs, or documentation. Use the [provider application configuration guide](https://docs.openpo.st/configuration/provider-applications.md) for the private operator contract and the [launch matrix](https://docs.openpo.st/operations/provider-launch-matrix.md) for the evidence required before any public claim changes. ### Telegram bot mode Source: [https://docs.openpo.st/providers/telegram.md](https://docs.openpo.st/providers/telegram.md) This page is for operators reviewing Telegram bot mode's certification boundary. Telegram bot mode is **not publicly available in OpenPost**. The repository contains controlled connection, publishing, observation, and analytics paths, but a configured bot or passing fixture test is not a Hosted service availability claim. Do not advertise or enable public Telegram bot operations unless the exact bot, destination, operation, policy mode, runtime controls, and current live evidence pass the [Provider Readiness and Launch Gate](https://docs.openpo.st/operations/provider-launch-matrix.md). #### Required readiness Connect, publish, observation, and analytics are independent gates. Evidence for one operation never enables another. The bot is instance-owned. Its token and webhook secret remain in encrypted operator configuration and must not be copied into workspace data, jobs, logs, connection links, or later API responses. A one-time `/connect` command is returned only through its authenticated issuance response and expires after 15 minutes. #### Implemented contract inventory The controlled certification paths can bind an eligible channel or supergroup, recheck destination identity and bot permissions, send text and media, preserve accepted message receipts, observe channel posts from installation onward, and record reaction counts. These facts describe repository code only; they do not make Telegram selectable for public Hosted accounts. Implemented publishing limits include 4,096 characters for a text message, 1,024 characters for a media caption, and up to 10 media items in one group. Caption overflow becomes a visible ordered follow-up. OpenPost does not invent historical coverage: observation begins at bot installation and does not backfill earlier messages. #### Operator boundary Keep every Telegram readiness control disabled for normal production traffic until current live certification exists. Reject webhook requests without the configured secret header, and never log raw update payloads or bot credentials. Use the [provider application configuration guide](https://docs.openpo.st/configuration/provider-applications.md) for the private operator contract and the [launch matrix](https://docs.openpo.st/operations/provider-launch-matrix.md) for the evidence required before any public claim changes. ### Threads Source: [https://docs.openpo.st/providers/threads.md](https://docs.openpo.st/providers/threads.md) This page is for operators configuring Threads and users connecting an account. Threads supports text, single media, replies, and 2-20 item media carousels, but the media URL requirement makes deployment details matter. #### What you need - Meta developer app - Threads API product enabled - `THREADS_CLIENT_ID` - `THREADS_CLIENT_SECRET` - Callback URL: `https://your-domain.com/api/v1/accounts/threads/callback` - Public `OPENPOST_MEDIA_URL` - Scopes: `threads_basic`, `threads_content_publish`, `threads_manage_replies`, `threads_manage_insights`, `threads_location_tagging` #### Important requirement Threads requires publicly reachable media URLs. Set: ```sh OPENPOST_MEDIA_URL=https://your-domain.com/media ``` OpenPost passes stored media MIME types to the Threads publisher and serves public media URLs with file extensions where possible. Threads still fetches media from your URL server-side, so the URL must be reachable by Meta and return the correct media bytes. For a carousel, OpenPost prepares each media item before it publishes the full carousel. An account version with one media item stays a normal image or video post. #### Comments Comments and replies is an optional feature per connected Threads account. It starts off for a new account. Enable it after connection or in Account details. OpenPost can list replies, send replies, and hide replies when enabled. The Threads API path in OpenPost does not delete replies. Threads does not support Direct messages in OpenPost, and Grow is not available for Threads. Disabling Comments and replies stops future Threads comment collection and reply actions without deleting stored replies or revoking authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. #### Analytics Analytics is an optional feature per connected Threads account. It starts off for a new account. Enable it after connection or in Account details. OpenPost collects follower counts and supported post views, likes, replies, reposts, quotes, and shares when enabled. Accounts connected before `threads_manage_insights` was added must be reconnected before collection can start. Disabling Analytics stops future Threads analytics collection without deleting stored metrics or revoking authorization. #### Location tags Threads location search requires `threads_location_tagging`. Accounts connected before this scope was added must be reconnected before locations can be searched or attached to a post. Your Meta app may also need access to this permission before non-test users can grant it. #### Local development For local testing, expose OpenPost through a tunnel such as ngrok so the callback URL and `/media/...` paths are publicly reachable. #### Common issues - Media URL points at localhost - Reverse proxy serves a different host than the callback configuration - Meta app missing the Threads API product or scopes ### TikTok Source: [https://docs.openpo.st/providers/tiktok.md](https://docs.openpo.st/providers/tiktok.md) This page is for operators configuring TikTok and users connecting an account. TikTok supports video and photo posts through OAuth and the Content Posting API. #### What you need - TikTok developer app - Login Kit and Content Posting API access - Social app entry with provider key `tiktok` - Callback URL: `https://your-domain.com/api/v1/accounts/tiktok/callback` - Public `OPENPOST_MEDIA_URL` or S3/R2 public media URL for Direct Post media URLs - Scopes: `user.info.basic`, `user.info.profile`, `user.info.stats`, `video.list`, `video.publish`, `video.upload`, and photo-post access when using image posts Example `OPENPOST_PROVIDER_APPS` entry: ```json [ { "provider": "tiktok", "client_id": "your-client-key", "client_secret": "your-client-secret", "redirect_uri": "https://your-domain.com/api/v1/accounts/tiktok/callback" } ] ``` #### Support and limits - New destinations default to Direct Post. Choose Upload explicitly to send a video to the TikTok inbox without publishing it. - Direct Post supports one video. - Inbox upload supports one video when enabled. - Supports 1-35 JPEG or WebP photos, up to 20 MB each, when TikTok app access allows the photo-post path. - Photo descriptions support up to 4,000 characters; video captions support up to 2,200 characters. - Text-only posts are not supported. - Pull-from-URL media must use public HTTPS URLs under a URL prefix or domain verified in the TikTok developer console. - Test the real app and account after TikTok approves access. - For Direct Post video, destination settings include a video preview for selecting the TikTok cover frame. #### Analytics Analytics is an optional feature per connected TikTok account. It starts off for a new account. Enable it after connection or in Account details. OpenPost uses `user.info.stats` for follower, following, likes, and video totals and `video.list` for published-video likes, comments, shares, and views when enabled. Reconnect accounts created before these scopes were added. Disabling Analytics stops future TikTok analytics collection without deleting stored metrics or revoking authorization. Direct messages, Comments and replies, and Grow are not available for TikTok. Analytics availability depends on provider support, required scopes, and plan access as distinct facts. #### Common issues - `OPENPOST_MEDIA_URL` points at localhost or a private host. - TikTok app lacks Content Posting API access or required scopes. - The TikTok app's redirect URI does not exactly match OpenPost's callback URL. ### Provider Troubleshooting Source: [https://docs.openpo.st/providers/troubleshooting.md](https://docs.openpo.st/providers/troubleshooting.md) Use this page when a provider connection, OAuth callback, media upload, or scheduled publish fails. Start by capturing a support snapshot: ```bash openpost instance diagnostics \ --instance https://your-domain.com \ --deployment docker-compose \ --provider youtube \ --logs-file ./openpost.log \ --json ``` The report checks server health, setup, tokens, and the current workspace. It also includes the last 100 log lines with private data removed. It does not print full tokens or server secrets. #### First Checks 1. Inspect the provider's exact state in **Accounts**, with `openpost provider readiness`, or through `GET /api/v1/provider-readiness`. 2. Compare the callback URL in the provider console with the OpenPost callback URL exactly. 3. Confirm `OPENPOST_APP_URL` is the public HTTPS app origin. 4. For media providers that fetch files server-side, confirm `OPENPOST_MEDIA_URL` or `OPENPOST_S3_PUBLIC_BASE_URL` is public HTTPS. 5. Open the failed post and read the error for each account. 6. Check logs around the callback or scheduled publish time. #### Common Symptoms | Symptom | Likely cause | Fix | | -------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | Provider shows `needs_configuration` | Missing provider app config | Add provider env vars, `OPENPOST_PROVIDER_APPS`, or save credentials through the instance-admin provider app API, then restart. | | OAuth redirects to the wrong host | `OPENPOST_APP_URL` or provider callback mismatch | Set one public HTTPS origin and update the provider console. | | OAuth succeeds but no account is saved | Provider returned no usable profile, page, channel, or business account | Confirm scopes, account ownership, and provider app review state. | | Text publishes but media fails | Media URL is private, local, or not HTTPS | Use public `OPENPOST_MEDIA_URL` or S3/R2 public media URLs. | | Scheduled post fails later | Token expired, revoked, or provider rejected the payload | Reconnect the account and retry with provider-compatible media. | | Provider returns permission errors | App lacks product access, scopes, or review approval | Enable the product and request the listed scopes in the provider console. | #### What to do when one account fails Activity shows each account on its own. A post can work on one account and fail on another. | Failure | What OpenPost does | Next action | | -------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | Invalid content, unsupported media, or duplicate content | Stops automatic retries for that account | Edit the draft, then publish again | | Expired or revoked authentication | Stops automatic retries | Reconnect the account | | Provider permission or billing restriction | Stops automatic retries | Open the provider or billing settings | | Rate limit, network failure, or social network outage | Waits longer between retries and uses the network's retry time when available | Wait, use **Retry account**, or retry all failed accounts from the notice | | Unknown rejection | Keeps a safe generic message and does not guess that retrying is safe | Review the draft and provider status | OpenPost saves the error type, status, safe error code, next retry time, and the action you can take. It does not show full social network responses or account keys. A manual retry uses the same post job. It can retry one failed account or all failed accounts without posting again to accounts that already worked. #### X - Connection requires OAuth 1.0a user authentication. OAuth 2-only apps will not work with the current adapter. - Callback must match `https://your-domain.com/api/v1/accounts/x/callback` unless `X_REDIRECT_URI` overrides it. - Media support needs OAuth 1.0a token + secret pairs. Reconnect old accounts if media uploads fail with an OAuth 1.0a reconnect message. - Video is safest as one MP4 attachment and cannot be mixed with images. #### Mastodon - Custom instances must be public HTTPS and must allow app registration. - OpenPost rejects private, loopback, link-local, multicast, and local-address instance hosts. - Preconfigured instances must preserve the exact `instance_url`; the persisted provider key is `mastodon:`. - Video support is instance-dependent. MP4, MOV, and WebM are the safest formats. #### Bluesky - Bluesky uses handle + app password, not OAuth. - Use an app password, not the account's main password. - Image posts support up to four images. - Video posts require one MP4 video under 100MB. Video cannot be mixed with images. #### LinkedIn - Callback must match `https://your-domain.com/api/v1/accounts/linkedin/callback` unless `LINKEDIN_REDIRECT_URI` overrides it. - LinkedIn permissions and app review can block posts or comments even when OAuth succeeds. - If thread child posts fail, set `LINKEDIN_DISABLE_THREAD_REPLIES=true` until the app has the required comment permissions. - Video upload uses LinkedIn's Videos API and still needs live-account re-verification before broad production claims. #### Threads - Threads requires the Meta app's Threads product and scopes: `threads_basic`, `threads_content_publish`, `threads_manage_replies`, `threads_manage_insights`, and `threads_location_tagging`. - If location search asks for reconnection, reconnect the Threads account so its token includes `threads_location_tagging`. The Meta app must have access to that permission. - Media URLs must be public HTTPS. Meta fetches media server-side and cannot use localhost, private DNS, or plain local paths. - For local testing, expose both the app callback and `/media/...` paths through a tunnel. - Threads posts support up to 20 JPEG, PNG, WebP, MP4, or MOV attachments, including mixed-media carousels. #### Facebook - Facebook connects Pages, not personal profile timelines. - `facebook account has no manageable pages` usually means the user has no eligible Pages, lacks full control of the Page, or the app lacks `business_management` or `pages_show_list`. Business Portfolio Pages require `business_management` for discovery. - Page publishing requires `pages_show_list`, `pages_read_engagement`, and `pages_manage_posts`, often with Meta app review. - Single-media posts and Stories support one JPEG, PNG, WebP, MP4, or MOV file. Multi-photo posts support 2–10 JPEG, PNG, or WebP images. Every media URL must use public HTTPS. #### Instagram - Instagram requires an Instagram Business or Creator account connected to a Facebook Page. - `facebook account has no connected instagram business accounts` means the authenticated Meta user has no eligible Page-backed Instagram account, lacks full control of the Page, or the app lacks required scopes. Business Portfolio Pages require `business_management` for discovery. - Publishing requires `instagram_basic`, `instagram_content_publish`, Page scopes, and often Meta app review. - Instagram does not publish text-only posts. Feed posts use one image, carousels use 2–10 JPEG, PNG, WebP, MP4, or MOV items, and Stories use one image or video. Reels use one video. #### TikTok - TikTok requires Login Kit plus Content Posting API access. - Required scopes are `user.info.basic`, `user.info.profile`, `video.publish`, and `video.upload`. - The redirect URI in the TikTok app must match `https://your-domain.com/api/v1/accounts/tiktok/callback` or the configured `redirect_uri`. - TikTok publishes one MP4 or MOV video, or a photo post with 1–35 JPEG or WebP images. Every media URL must use public HTTPS; text-only posts are not supported. #### YouTube - Enable YouTube Data API v3 in the Google Cloud project. - The OAuth app needs profile/email scopes plus `youtube.readonly` and `youtube.upload`. - `google account has no YouTube channels` means the authenticated Google account has no eligible channel available to the OAuth app. - `invalidTitle` usually means the first non-empty line of the post or YouTube variant is invalid for a video title. - Uploads are private by default and support one video attachment in the current adapter. #### Escalation Checklist Before filing an issue or escalating an operator incident, include: - Output from `openpost instance diagnostics --provider --logs-file --json` - Provider name and account type being tested - Deployment method and public app/media URLs - Exact callback URL configured in the provider console - Failed account error from the post, if the account connection succeeded - Whether the same account can publish text-only content ### X Source: [https://docs.openpo.st/providers/x.md](https://docs.openpo.st/providers/x.md) This page is for operators configuring X and users connecting an account. #### What you need - X developer app - `X_CLIENT_ID` - `X_CLIENT_SECRET` - Callback URL: `https://your-domain.com/api/v1/accounts/x/callback` - OAuth 1.0a user authentication enabled in the X developer portal #### Auth model OpenPost currently uses X OAuth 1.0a end-to-end. Configure an app type that supports OAuth 1.0a user context and the callback URL above. #### Account-specific limits OpenPost reads the connected account's `subscription_type` from the authenticated X profile and applies the matching publishing limits. | Connected X account | Text limit | Video duration | Video size | | -------------------------------- | -------------------------: | -------------: | ---------: | | Standard, unknown, or stale tier | 280 weighted characters | 140 seconds | 512 MiB | | Basic, Premium, or Premium+ | 25,000 weighted characters | 4 hours | 16 GiB | X does not count every Unicode character as one. OpenPost follows X's weighted counting rules, treats URLs as 23 characters, normalizes composed text, and keeps emoji sequences together. The counter and server validation use the same rules. The standard profile is the safe fallback. OpenPost uses it when X omits the subscription tier, the account profile cannot be refreshed, or cached account capability data is stale. Publishing validation refreshes the tier through the connected account before applying the final text and video limits. These account limits do not replace X API access, API limits, or post rules. Test each post type with the real account. X documents the `subscription_type` user field, weighted character counting, and longer Premium video limits. #### Media upload OpenPost streams images and videos to X through the OAuth 1.0a media upload API. Videos use chunked upload, so large subscribed-account uploads are not buffered fully in application memory. #### Analytics Analytics is an optional feature per connected X account. It starts off for a new account. Enable it after connection or in Account details. OpenPost collects account followers, following, and post totals plus published-post likes, replies, reposts, quotes, bookmarks, and impressions when enabled. These reads use the same OAuth 1.0a account connection and remain subject to X API access and limits. Disabling Analytics stops future X analytics collection without deleting stored metrics or revoking authorization. #### Comments and inbox Direct messages and Comments and replies are separate optional features per connected X account. Each starts off for a new account. Enable them after connection or in Account details. - Comments and replies: OpenPost can list replies, send replies, like or unlike replies, and delete replies posted by the connected account when enabled. Disabling it stops future X comment collection and reply actions without deleting stored replies or revoking authorization. - Direct messages: OpenPost can collect and send direct messages when enabled. The X app and account must have Direct Messages access, and an older connection may need to be reconnected. Disabling it stops future message collection and sending without deleting stored messages or revoking authorization. Grow is not available for X. Availability for each supported feature depends on provider support, required scopes, and plan access as distinct facts. #### Local development callback ```txt http://localhost:8080/api/v1/accounts/x/callback ``` #### Common errors - Callback URL mismatch in the X developer portal - Missing OAuth 1.0a user auth enablement - Wrong redirect URI override via `X_REDIRECT_URI` - X profile permissions that do not return `subscription_type`; OpenPost will connect the account but use standard limits ### YouTube Source: [https://docs.openpo.st/providers/youtube.md](https://docs.openpo.st/providers/youtube.md) This page is for operators configuring YouTube and users connecting a channel. YouTube supports Shorts and long-form video uploads. It uses Google OAuth, asks the user to choose a channel, and stores the Google refresh token for scheduled uploads. #### Requirements - Google Cloud OAuth app with the YouTube Data API v3 enabled - OAuth redirect URL: ```text https://your-domain.com/api/v1/accounts/youtube/callback ``` - OAuth scopes: - `https://www.googleapis.com/auth/userinfo.profile` - `https://www.googleapis.com/auth/userinfo.email` - `https://www.googleapis.com/auth/youtube.readonly` - `https://www.googleapis.com/auth/youtube.upload` - `https://www.googleapis.com/auth/youtube` - One video attachment on the OpenPost post or YouTube-specific variant #### Configuration Configure YouTube through the provider app registry. For bootstrap/self-hosting, use `OPENPOST_PROVIDER_APPS`: ```json [ { "provider": "youtube", "client_id": "your-google-oauth-client-id", "client_secret": "your-google-oauth-client-secret" } ] ``` If `redirect_uri` is omitted, OpenPost derives it from `OPENPOST_APP_URL`. #### Current Scope - Connects a selected YouTube channel. - Uploads one video through the YouTube Data API `videos.insert` endpoint with resumable-upload handling. - Uploads videos as private by default, with YouTube-specific privacy settings available through publication/variant settings. - Supports title, description, tags, category, made-for-kids, thumbnail, and playlist settings when provided. A thumbnail can be uploaded or captured from the attached video in destination settings. - Uses the post or account version for a fallback title and description when those fields are empty. - Supports scheduling and platform variants through the normal OpenPost post flow. - Lists comments, sends replies, moderates comments, and deletes comments from the connected channel when Comments and replies is enabled for that channel and Google grants access. Comments and replies is an optional per-account feature that starts off. - YouTube does not support Direct messages in OpenPost, and Grow is not available for YouTube. #### Current Limits - Comment and moderation actions require YouTube permissions and can vary by channel or comment. - Test a live account before you rely on YouTube publishing, especially for app review, playlists, and thumbnails. #### Analytics Analytics is an optional feature per connected YouTube channel. It starts off for a new account. Enable it after connection or in Account details. OpenPost keeps Data API channel and video counters labeled as lifetime values. Separate, date-bounded YouTube Analytics reports collect views, watch time, average view duration, average viewed percentage, subscriber gains and losses, likes, comments, and shares when Google returns those columns. Accounts connected before the `yt-analytics.readonly` scope was added must reconnect. Disabling Analytics stops future YouTube analytics collection without deleting stored metrics or revoking authorization. Direct messages, Comments and replies, and Analytics are optional and per connected account. Disabling a feature stops future provider reads and writes without deleting history or revoking provider authorization. Availability depends on provider support, required scopes, and plan access as distinct facts. #### Troubleshooting - `google account has no YouTube channels` usually means the authenticated Google user has no YouTube channel available to the OAuth app. - `invalidTitle` usually means the first line of the post or variant is empty or invalid after trimming. - `mediaBodyRequired` usually means the video file could not be read from OpenPost media storage. - Upload permission errors usually mean the Google Cloud project lacks YouTube Data API v3 access or the OAuth app has not been verified for the requested scopes. ## CLI ### Sign In From the CLI Source: [https://docs.openpo.st/cli/authentication.md](https://docs.openpo.st/cli/authentication.md) This page is for people signing the OpenPost CLI in from a browser, remote server, or automation job. The CLI signs in to a running OpenPost server over HTTPS. It never sees your password, TOTP code, passkey, or social account keys. #### Sign in with a browser Browser login is the default: ```sh openpost auth login http://localhost:8080 ``` The CLI opens an OpenPost approval page and waits for you to approve or deny access. Only a signed-in browser session can make that decision; an API or CLI token cannot approve another device credential. Before approval, choose whether workspace-owned actions apply to one workspace or every workspace the token is permitted to access. A bound token can use workspace-owned resources only in the selected workspace and only while your account remains a member there. After approval, the server creates an API token and returns it once. The CLI saves it for later commands. All-workspace access can also apply to workspaces you join later when their organization policy permits it, so use it only for account-wide automation. #### Sign in on a server For SSH sessions or servers without a browser: ```sh openpost auth login http://localhost:8080 --device ``` The CLI prints the verification URL and user code. Open that URL on another device, sign in, and approve the session. #### Sign in with a token For automation, create an API token in **Settings → Personal → Developer access**, then pass it through stdin: ```sh printf '%s\n' "$OPENPOST_TOKEN" | openpost auth login http://localhost:8080 --with-token ``` #### Where the CLI saves tokens By default, the CLI stores tokens in the operating system keyring through `github.com/zalando/go-keyring`. If no keyring is available, `--insecure-storage` writes the token to an XDG `credentials.json` file with `0600` permissions. Anyone who can read that file can use the token. #### Token access CLI tokens use `cli:full`. They can read and change workspaces, social accounts, posts, media, jobs, and API tokens. A workspace selection limits workspace-owned resources and blocks organization-level resources, but it does not remove the account-level commands included in `cli:full`. An all-workspace token retains the account- and organization-level commands allowed by `cli:full`, while organization SSO and token policies can exclude protected workspaces. If its owner is an instance administrator, it also retains the explicitly typed provider-certification test operations; the general instance control plane still requires a signed-in browser session. For narrower automation, use an `api:read` or `api:write` token instead. Use **Settings → Personal → Developer access** to see each token's status, expiration, last use, scope, and workspace boundary. Remove tokens you no longer use. See [API Tokens](https://docs.openpo.st/development/api-tokens.md) for the complete scope and lifetime contract. #### Moving a Hosted profile to openpo.st The CLI does not rewrite instance profiles. If a profile points to the former Hosted address, replace that named profile and sign in on the new host: ```sh openpost instance add hosted https://app.openpo.st openpost instance use hosted openpost auth login https://app.openpo.st ``` Use your existing profile name in place of `hosted`. This leaves self-hosted profiles unchanged. Existing tokens remain stored under their profile, but a token whose issuer or audience is bound to the former host may require a new login. ### Automate the CLI Source: [https://docs.openpo.st/cli/automation.md](https://docs.openpo.st/cli/automation.md) You can use the CLI in CI, cron, and deploy jobs without a browser. Create an API token in OpenPost, then pass it through an environment variable or standard input. #### Environment | Variable | Purpose | | --------------------------- | ------------------------------------------------------------------------------------------------ | | `OPENPOST_TOKEN` | API token for use without a browser. | | `OPENPOST_INSTANCE` | Default OpenPost instance URL. | | `OPENPOST_WORKSPACE` | Default workspace ID or name. | | `OPENPOST_OUTPUT_JSON=true` | Default JSON output for scripts. You can also set `output = "json"` in the selected CLI profile. | | `OPENPOST_PROFILE` | Selects a named CLI profile. | Useful flags: | Flag | Purpose | | ------------------------ | ----------------------------------------------------------------------------------------------------- | | `--yes` | Skip confirmation prompts. | | `--json` | Print machine-readable JSON for one command. | | `--accounts ` | Select accounts by ID, short name, or social network. Omit it for a draft with no accounts. | | `--schedule next-slot` | Use the next available posting schedule slot instead of posting immediately or choosing a fixed time. | For repeat jobs, always set `--accounts` and `--schedule next-slot`. `next-slot` uses the posting schedule for the selected workspace. `--json` only changes the output. Commands that delete data or create many items still need `--yes` when no person can answer the prompt. #### GitHub Actions Example ```yaml name: Daily Build Summary on: schedule: - cron: "0 17 * * 1-5" jobs: post-summary: runs-on: ubuntu-latest env: OPENPOST_INSTANCE: ${{ secrets.OPENPOST_INSTANCE }} OPENPOST_TOKEN: ${{ secrets.OPENPOST_TOKEN }} OPENPOST_WORKSPACE: ${{ secrets.OPENPOST_WORKSPACE }} OPENPOST_OUTPUT_JSON: "true" steps: - name: Install OpenPost CLI run: curl -fsSL https://raw.githubusercontent.com/getopenpost/openpost/main/scripts/install-cli.sh | sh - name: Post summary run: | openpost post create \ --content "Daily build completed for ${GITHUB_REPOSITORY}@${GITHUB_SHA}" \ --accounts x \ --schedule next-slot \ --yes \ --json ``` See every command and flag in the [CLI Reference](https://docs.openpo.st/reference/cli.md). ### OpenPost CLI Source: [https://docs.openpo.st/cli/index.md](https://docs.openpo.st/cli/index.md) The OpenPost CLI controls a running OpenPost instance from a terminal or automation job. It talks to the same `/api/v1` HTTP API as the web app, authenticates with revocable API tokens, and never reads the server database directly. The CLI never bypasses server authorization, validation, quota, or audit checks. This page is for people using OpenPost from a terminal, script, CI job, or scheduled task. Use it when you want to: - Create drafts, scheduled posts, and threads from scripts - Check account setup and supported post types - Upload, inspect, update, and clean up media from a terminal - Set a weekly posting schedule - Manage workspaces, account names, jobs, and API tokens - Use OpenPost from CI, cron, deploy scripts, or your terminal #### Typical setup ```sh openpost instance add local http://localhost:8080 openpost instance use local openpost instance health openpost auth login http://localhost:8080 openpost workspace use personal ``` Then inspect accounts and pass explicit selectors to posting commands: ```sh openpost account list openpost provider readiness openpost post create --accounts main-x,linkedin --content "Hello from OpenPost" --schedule next-slot ``` #### Docs - [Installation](https://docs.openpo.st/cli/installation.md) - [Authentication](https://docs.openpo.st/cli/authentication.md) - [Posting](https://docs.openpo.st/cli/posting.md) - [Automation](https://docs.openpo.st/cli/automation.md) - [Generated command reference](https://docs.openpo.st/reference/cli.md) OpenPost builds the command reference from the CLI code, so its commands and flags stay up to date. ### CLI Installation Source: [https://docs.openpo.st/cli/installation.md](https://docs.openpo.st/cli/installation.md) Install the OpenPost CLI when you want to manage a running OpenPost instance from a terminal or automation job. Other operating systems and architectures do not have a maintained release binary. #### Install from GitHub Releases The install script downloads the latest matching `openpost-cli-*` release asset and installs it as `openpost`: ```sh curl -fsSL https://raw.githubusercontent.com/getopenpost/openpost/main/scripts/install-cli.sh | sh ``` It supports Linux on `amd64` and `arm64`, and macOS on Apple Silicon (`arm64`). To install the desktop MCP stdio proxy at the same time: ```sh curl -fsSL https://raw.githubusercontent.com/getopenpost/openpost/main/scripts/install-cli.sh | sh -s -- --with-mcp ``` That installs the matching `openpost-mcp-*` release asset as `openpost-mcp`. #### Manual Install Download the matching CLI binary from GitHub Releases, then put it on your `PATH`. ```sh chmod +x openpost-cli-linux-amd64 sudo mv openpost-cli-linux-amd64 /usr/local/bin/openpost openpost --version ``` For MCP clients, also download the matching `openpost-mcp-*` asset and put it on your `PATH` as `openpost-mcp`. #### Build from Source From the repository root: ```sh devenv shell -- bash -lc 'cd cli && go build -ldflags="-s -w" -o ../openpost ./cmd/openpost' ``` Or from inside `cli/`: ```sh go build ./cmd/openpost ``` #### First Run ```sh openpost instance add local http://localhost:8080 openpost instance use local openpost auth login http://localhost:8080 openpost workspace list openpost workspace use personal ``` For every command and flag, see the [generated CLI reference](https://docs.openpo.st/reference/cli.md). ### Posting with the CLI Source: [https://docs.openpo.st/cli/posting.md](https://docs.openpo.st/cli/posting.md) Use `openpost post` for quick post drafts, `openpost thread` for markdown threads, `openpost publication` for account-specific renditions and formats, and `openpost media` for attachments. Drafts from all three creation commands open in the unified publication composer. #### Check Account Support Check account setup and supported post types before you publish: ```sh openpost provider readiness --json openpost provider capabilities --provider youtube --content-profile long_video --json ``` #### Choose Accounts Use `--accounts` to select accounts by ID, short name, or social network: ```sh openpost post create --accounts x,linkedin --content 'Shipping today.' ``` If `--accounts` is omitted, `post create` and `thread create` make drafts with no accounts. Add accounts later from the web editor or update command. #### Schedule Posts Natural language and RFC3339 are supported: ```sh openpost post create --accounts x --content 'Shipping today.' --schedule 'tomorrow 2pm' openpost post create --accounts x --file launch.md --schedule '2026-06-15T09:00:00+01:00' ``` Use the next available workspace posting slot: ```sh openpost post create --accounts x --content 'Shipping today.' --schedule next-slot openpost thread create launch.md --accounts x,linkedin --schedule next-slot ``` #### Attach Media Upload media first, then pass the returned media ID to a post command: ```sh openpost media upload ./image.png --alt 'Product screenshot' openpost post create --accounts x --content 'New queue view is live.' --media --schedule 'next monday 9am' ``` `--media` also accepts a local file path and uploads it before creating the post. Inspect storage and usage, update alt text, or delete an unused item: ```sh openpost media storage openpost media usage openpost media update --alt 'Revised product screenshot' openpost media delete ``` #### Create Threads Create a markdown file with optional front matter and `---` separators: ```md --- accounts: x,linkedin schedule: next-slot --- We shipped the OpenPost CLI today. --- It supports browser login, device mode for SSH hosts, token-based automation, and next-slot scheduling. ``` Then create the thread: ```sh openpost thread create launch.md ``` #### Other Post Types Use `openpost publication create` for Stories, short videos, long videos, link posts, and other posts that need extra settings. ```sh openpost publication create --content-profile link_share --accounts linkedin --url https://openpo.st --content 'Launch notes' openpost publication create --content-profile short_video --accounts youtube,tiktok --video-title 'Short title' --video-description 'YouTube description' --caption 'TikTok caption' --media ./short.mp4 openpost publication create --content-profile long_video --accounts youtube --video-title 'Full walkthrough' --video-description 'Long-form description' --privacy private --media ./walkthrough.mp4 --schedule next-slot openpost publication schedule pub_123 --at 'tomorrow 9am' ``` Check the post before you schedule or publish it: ```sh openpost publication validate pub_123 --json openpost publication publish-now pub_123 ``` #### Manage Posting Slots Posting slots use the workspace timezone and `0=Sunday` through `6=Saturday`: ```sh openpost schedule list openpost schedule create --day 1 --hour 9 --minute 30 --label Morning openpost schedule next ``` `openpost schedule suggest --posts-per-day 2` creates fourteen active slots, so it asks for confirmation. #### Schedule Inputs | Input | Resolution | | ---------------------------------- | ------------------------------------------------------------------------- | | `now` | The start of the next minute. | | `draft` | No scheduled time; the post remains a draft. | | `next-slot` / `next slot` / `slot` | The next available posting schedule slot from the server. | | `2pm` | Today at 14:00 if still in the future, otherwise tomorrow at 14:00. | | `tomorrow 2pm` | Tomorrow at 14:00 in the resolved workspace/profile/local timezone. | | `in 3 hours` | Three hours after the command runs. | | `next monday 9am` | The next Monday after today at 09:00. | | `2026-06-15T09:00:00+01:00` | The exact RFC3339 instant with the supplied offset. | | `2026-06-15 09:00` | The local date and time in the resolved workspace/profile/local timezone. | `today` or `tomorrow` without a time is rejected so scheduled posts do not land at an accidental default time. ## MCP ### Use OpenPost With an AI Assistant Source: [https://docs.openpo.st/mcp/index.md](https://docs.openpo.st/mcp/index.md) MCP lets AI tools work with your OpenPost account. An assistant can read your drafts, prepare posts, and schedule approved work. It never receives the keys for your social accounts. > **Choose what the assistant can do** > > Use `mcp:read` when an assistant only needs to view data. OpenPost blocks all changes made with this token. Use `mcp:full` when an assistant needs to create, change, schedule, or publish posts. Limit the token to one workspace when you can. Check every account and approve each publish action. Use it when you want an assistant to: - View workspaces, accounts, media, drafts, and scheduled posts - Turn an idea into a draft - Write a separate version for each account - Add saved media or upload media from a public link - Find the next open posting time - Schedule approved posts or cancel scheduled posts #### Ways to connect ##### ChatGPT-style clients Use the remote MCP endpoint from your OpenPost instance: ```txt https://your-openpost-host.example/mcp ``` OAuth-aware clients can use OpenPost's browser account-linking flow. Clients that need a manual token can create `mcp:read` or `mcp:full` access from **Settings → Personal → Developer access**. OAuth requests default to `mcp:full` when they omit a scope, so choose `mcp:read` explicitly for inspection-only connections. When you approve OAuth or create a token, limit it to the current workspace unless the client needs access to all your workspaces. ##### Desktop MCP clients Install and authenticate the OpenPost CLI with the MCP proxy, then run the local stdio proxy: ```sh curl -fsSL https://raw.githubusercontent.com/getopenpost/openpost/main/scripts/install-cli.sh | sh -s -- --with-mcp openpost --profile local auth login https://your-openpost-host.example openpost-mcp --profile local ``` The proxy uses the selected CLI profile to connect to the remote `/mcp` address. It does not open the database or need social account keys on your computer. #### Available tools OpenPost gives the assistant a small set of tools. The assistant can then look up the exact action it needs. An `mcp:read` connection gets three tools: - `search_operations` finds actions that only view data. - `query_operation` runs an action that only views data. - `render_scheduler_widget` shows a schedule in clients that support it. An `mcp:full` connection also gets `execute_operation`, which can change OpenPost or contact a social network. `search_operations` tells the assistant whether it must use `query_operation` or `execute_operation`. It returns nothing when the request is unclear or OpenPost cannot do it. OpenPost also checks the tool choice: `query_operation` cannot make changes, and `execute_operation` cannot run view-only actions. These tools cover workspaces, social networks, accounts, media, drafts, account versions, all post types, checks, schedules, publishing, status, cancellation, activity, comments, and open posting times. You can ask for what you want in plain language. #### Safe steps 1. Start with an `mcp:read` token limited to one workspace. Ask the assistant to check the workspace, accounts, recent media, and account setup. 2. If the assistant must create or change work, use an `mcp:full` token limited to that workspace. Test each account and post type before you rely on it. 3. Open the post in the web app. Check the text, account versions, media, alt text, post type, and time. 4. Approve `execute_operation` only when the content and accounts are correct. 5. Check Activity after publishing. A scheduled post has not yet been published. OpenPost checks workspace access and account ownership before it reads or changes data. Schedules and media uploads use the same plan limits as the web app and CLI. For a sample brief, prompt, account versions, test log, and review list, see the public OpenPost Launch Kit. These are examples, not proof of a live publish. #### What OpenPost protects | Area | What OpenPost does | What you must do | | ------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | Social account keys | Encrypts access and refresh tokens and never returns them through MCP. | Protect your OpenPost encryption key, database, backups, and server. | | Workspace access | Lets you limit a token to one workspace and checks account ownership. | Grant access to the smallest useful workspace. Remove access when the work ends. | | View or change | Blocks changes with `mcp:read`. Checks that the client uses the right tool with `mcp:full`. | Start with `mcp:read`. Approve each `execute_operation` call after you grant `mcp:full`. | | Post checks | Checks current media rules, account support, and plan limits. | Test the exact account and post type. Social networks can still change or reject a post. | | Your review | Keeps drafts and account versions in the web app so you can edit them. | Review the work before you approve it. OpenPost does not force this step. | #### View use or remove access Recent MCP actions appear in **Settings → Personal → Developer access** when the client uses its own MCP or CLI token. Remove the token there to disconnect the client. For protocol details, Apps SDK metadata, OAuth discovery, and implementation notes, see [MCP And ChatGPT App](https://docs.openpo.st/development/mcp.md) in the developer docs. ## Installation ### Android App Source: [https://docs.openpo.st/installation/android.md](https://docs.openpo.st/installation/android.md) This page is for Android users installing OpenPost and connecting it to OpenPost Hosted or a self-hosted instance. OpenPost ships a standalone native Android app built with Expo. It uses the same API as the web app and does not load the web app in a wrapper. #### Install from a release Official GitHub releases publish this APK: ```text openpost-app-android.apk ``` Download it from GitHub Releases, then open the file on your Android device. Because this is a release APK distributed outside the Play Store, Android may ask you to allow installs from your browser or file manager. Only install APKs from the official OpenPost release page. #### Connect to OpenPost 1. Open the Android app. 2. Choose OpenPost Hosted, or enter the public HTTPS URL of your server. 3. Sign in with email and password, or pair the device from a signed-in browser. 4. Choose a workspace. A self-hosted server must be reachable from the phone. `localhost`, private development names, URL credentials, and plain HTTP do not work in the production app. #### Server requirements - Set `OPENPOST_APP_URL` to the public URL users open. - Preserve the public host and scheme in reverse proxy headers. - Match provider OAuth callback URLs to the public server URL. - Use HTTPS with a certificate the Android device trusts. See [Reverse Proxy](https://docs.openpo.st/installation/reverse-proxy.md) and [CORS and URLs](https://docs.openpo.st/configuration/cors-and-urls.md) for server setup. #### Mobile features The first native app covers the common work away from a desk: - Capture and edit drafts - Receive text, links, and photos through Android sharing - Select Social Sets or individual destinations - Adjust text per destination - Schedule, publish, cancel, retry, and inspect provider results - Review the calendar and queue Account connections, billing, and advanced settings remain in the web app. The mobile Drafts menu links to it. #### Build from source The mobile source lives in `mobile/`. From that directory: ```sh bun install --frozen-lockfile bun run check bun run build:android:apk ``` The installable ARM64 preview APK is written to: ```text mobile/android/app/build/outputs/apk/release/app-release.apk ``` Local preview builds use Android's generated debug key. They are for an emulator, a device, or private testing. Official releases use the project release key in GitHub Actions. See `mobile/README.md` in the source tree for the full emulator, package inspection, CI candidate, and signing procedure. #### Release safety Candidate CI builds an unsigned universal APK and keeps its SHA-256 digest. The tag release workflow downloads that exact artifact, signs it with the project release key, verifies the package and signer, and only then publishes `openpost-app-android.apk`. CI also uploads a separate debug-signed preview APK. Its artifact name includes `preview`, and it never enters the release workflow. ### Single Binary Source: [https://docs.openpo.st/installation/binary.md](https://docs.openpo.st/installation/binary.md) OpenPost can run as a single Go binary with the frontend embedded into the executable. #### 1. Download a release Download the binary for your platform from GitHub Releases. Expected release assets: - Linux x86_64: `openpost-server-linux-amd64` - macOS Apple Silicon: `openpost-server-darwin-arm64` - Windows x86_64: `openpost-server-windows-amd64.exe` #### 2. Create `.env` Create a working directory, generate two independent secrets, and write a private `.env` file: ```bash umask 077 jwt_secret="$(openssl rand -hex 32)" encryption_key="$(openssl rand -hex 32)" cat > .env </dev/null read -r container_id < /run/${name}/ctr-id [[ "$container_id" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid ${name} container ID" >&2 exit 1 } for unit_type in timer service; do while read -r unit _; do [ -n "$unit" ] || continue ${pkgs.systemd}/bin/systemctl stop "$unit" || true if [ "$unit_type" = service ]; then ${pkgs.systemd}/bin/systemctl reset-failed "$unit" || true fi done < <( ${pkgs.systemd}/bin/systemctl list-units \ --all --plain --no-legend "$container_id-*.$unit_type" ) done fi ${pkgs.podman}/bin/podman stop --ignore --cidfile=/run/${name}/ctr-id ''; openpostOpsAlert = pkgs.writeShellScript "openpost-ops-alert" '' set -euo pipefail [ "$#" -eq 1 ] || { echo "expected a failed systemd unit" >&2; exit 1; } unit="$1" [[ "$unit" =~ ^[A-Za-z0-9@_.:-]+$ ]] || { echo "invalid systemd unit" >&2; exit 1; } webhook_url="$(${pkgs.coreutils}/bin/tr -d '\r\n' < ${ config.sops.templates."openpost-feedback-webhook".path })" case "$webhook_url" in https://discord.com/api/webhooks/*|https://discordapp.com/api/webhooks/*) ;; *) echo "OpenPost operations webhook is not an approved Discord URL" >&2; exit 1 ;; esac result="$(${pkgs.systemd}/bin/systemctl show "$unit" --property=Result --value 2>/dev/null || printf unknown)" message="OpenPost operations failure on $(${pkgs.inetutils}/bin/hostname): $unit result=$result at $(${pkgs.coreutils}/bin/date -u +%Y-%m-%dT%H:%M:%SZ)" payload="$(${pkgs.jq}/bin/jq -cn --arg content "$message" '{content: $content}')" printf 'url = "%s"\n' "$webhook_url" \ | ${pkgs.curl}/bin/curl \ --config - \ --fail \ --silent \ --show-error \ --connect-timeout 10 \ --max-time 30 \ --header 'Content-Type: application/json' \ --data-binary "$payload" \ --output /dev/null ''; in { options.vps.openpost = { enable = lib.mkEnableOption "Enable OpenPost"; edition = lib.mkOption { type = lib.types.enum [ "selfhost" "cloud" ]; default = "selfhost"; description = '' OpenPost edition. `selfhost` keeps SQLite and local media defaults. `cloud` wires the container for Postgres and S3-compatible storage. ''; }; domain = lib.mkOption { type = lib.types.str; default = "app.openpo.st"; description = "Domain for OpenPost"; }; timezone = lib.mkOption { type = lib.types.str; default = "Europe/Lisbon"; description = "Timezone for OpenPost"; }; image = lib.mkOption { type = lib.types.str; default = "ghcr.io/getopenpost/openpost:latest"; description = '' OpenPost container image. Keep this aligned with the local tag promoted by the signed deployment hook because the VPS service uses pullPolicy = "never" and must not fetch an unverified registry reference at restart. ''; }; pullPolicy = lib.mkOption { type = lib.types.enum [ "always" "missing" "never" ]; default = "always"; description = '' Podman image pull policy for OpenPost. The default keeps the hosted service from reusing a stale local `latest` image after a Nix switch. ''; }; bootstrapDigest = lib.mkOption { type = lib.types.nullOr (lib.types.strMatching "sha256:[0-9a-f]{64}"); default = null; description = "Immutable OpenPost image digest used only to seed a clean host."; }; bootstrapRevision = lib.mkOption { type = lib.types.nullOr (lib.types.strMatching "[0-9a-f]{40}"); default = null; description = "Source revision required on the clean-host bootstrap image."; }; offsiteBackup.enable = lib.mkEnableOption "encrypted off-host OpenPost backups and log archives"; extraEnvironment = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; description = '' Extra OpenPost environment variables. Use this for non-secret cloud settings, temporary overrides, or *_FILE pointers to mounted secrets. ''; }; extraEnvironmentFiles = lib.mkOption { type = lib.types.listOf lib.types.path; default = [ ]; description = '' Extra env files passed to the OpenPost container. In cloud mode this should provide OPENPOST_DATABASE_URL, S3 credentials, Paddle billing secrets, or *_FILE pointers unless they are set via extraEnvironment. ''; }; extraOptions = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = '' Extra Podman options for OpenPost. Use this to bind-mount additional secret files referenced by *_FILE environment variables. ''; }; }; config = lib.mkIf cfg.enable { sops.secrets = lib.genAttrs ( [ "openpost_jwt_secret" "openpost_encryption_key" "openpost_posthog_project_token" "openpost_provider_apps" "openpost_google_auth_client_id" "openpost_google_auth_client_secret" "openpost_pexels_api_key" "openpost_pixabay_api_key" "openpost_unsplash_access_key" "openpost_feedback_webhook" "openpost_smtp_password" "openpost_twitter_client_id" "openpost_twitter_client_secret" "openpost_linkedin_client_id" "openpost_linkedin_client_secret" "openpost_threads_client_id" "openpost_threads_client_secret" "openpost_openrouter_api_key" ] ++ lib.optionals isCloud [ "openpost_postgres_password" "openpost_s3_endpoint" "openpost_s3_region" "openpost_s3_bucket" "openpost_s3_access_key_id" "openpost_s3_secret_access_key" "openpost_s3_public_base_url" "openpost_paddle_api_key" "openpost_paddle_client_token" "openpost_paddle_webhook_secret" ] ++ lib.optionals (isCloud && cfg.offsiteBackup.enable) [ "openpost_backup_s3_endpoint" "openpost_backup_s3_region" "openpost_backup_s3_bucket" "openpost_backup_s3_access_key_id" "openpost_backup_s3_secret_access_key" "openpost_backup_restic_password" ] ) (_: { }); # Create persistent directories # Note: Container runs as user 'openpost' (UID 1000) systemd.tmpfiles.rules = [ "d /var/lib/openpost 0755 root root -" ] ++ lib.optionals isCloud [ "d /var/lib/openpost/postgres 0700 70 70 -" "d /var/backup/openpost 0700 root root -" ] ++ lib.optionals (!isCloud) [ "d /var/lib/openpost/data 0750 ${toString openpostHostUid} ${toString openpostHostGid} -" "d /var/lib/openpost/data/db 0750 ${toString openpostHostUid} ${toString openpostHostGid} -" "d /var/lib/openpost/data/media 0750 ${toString openpostHostUid} ${toString openpostHostGid} -" ]; assertions = [ { assertion = (cfg.bootstrapDigest == null) == (cfg.bootstrapRevision == null); message = "OpenPost bootstrapDigest and bootstrapRevision must be configured together."; } { assertion = cfg.bootstrapDigest == null || lib.hasSuffix ":latest" cfg.image; message = "OpenPost clean-host bootstrap requires the managed image to use the :latest tag."; } ]; # OpenPost HTTP application. Hosted workers run in a separate container so # web traffic and durable job throughput can scale and fail independently. virtualisation.oci-containers.containers.openpost = { inherit (cfg) image; user = "${toString openpostContainerUid}:${toString openpostContainerGid}"; cmd = [ "./openpost" (if isCloud then "web" else "all") ]; environment = openpostApplicationEnvironment; environmentFiles = openpostApplicationEnvironmentFiles; volumes = lib.optionals (!isCloud) [ "/var/lib/openpost/data:/data" ]; dependsOn = lib.optionals isCloud [ "openpost-postgres" ]; ports = [ "127.0.0.1:${toString openpostHostPort}:${toString openpostContainerPort}" ]; extraOptions = openpostApplicationOptions ++ [ "--health-cmd=sh -ec 'attempt=0; until wget --spider http://localhost:${toString openpostContainerPort}/api/v1/health; do attempt=$((attempt + 1)); [ \"$attempt\" -ge 60 ] && exit 1; sleep 1; done'" "--health-interval=30s" "--health-timeout=75s" "--health-retries=3" "--health-start-period=60s" "--memory=1536m" "--memory-reservation=256m" "--memory-swap=1536m" "--cpus=1.5" "--pids-limit=512" ] ++ openpostFileSecretMounts ++ cfg.extraOptions; }; virtualisation.oci-containers.containers.openpost-worker = lib.mkIf isCloud { inherit (cfg) image; user = "${toString openpostContainerUid}:${toString openpostContainerGid}"; cmd = [ "./openpost" "worker" ]; environment = openpostApplicationEnvironment; environmentFiles = openpostApplicationEnvironmentFiles; dependsOn = [ "openpost-postgres" ]; extraOptions = openpostApplicationOptions ++ [ "--health-cmd=sh -ec 'kill -0 1'" "--health-interval=30s" "--health-timeout=3s" "--health-retries=3" "--health-start-period=5s" "--memory=1024m" "--memory-reservation=256m" "--memory-swap=1024m" "--cpus=1.0" "--pids-limit=512" ] ++ openpostFileSecretMounts ++ cfg.extraOptions; }; virtualisation.oci-containers.containers.openpost-postgres = lib.mkIf isCloud { image = openpostPostgresImage; environmentFiles = [ config.sops.templates.openpost-postgres-env.path ]; volumes = [ "/var/lib/openpost/postgres:/var/lib/postgresql/data" ]; extraOptions = [ "--network=podman" "--health-cmd=sh -ec 'attempt=0; until pg_isready -U ${openpostPostgresUser} -d ${openpostPostgresDatabase}; do attempt=$((attempt + 1)); [ \"$attempt\" -ge 60 ] && exit 1; sleep 1; done'" "--health-interval=10s" "--health-timeout=75s" "--health-retries=12" "--health-start-period=60s" "--memory=1536m" "--memory-reservation=256m" "--memory-swap=1536m" "--cpus=1.5" "--pids-limit=256" ]; }; sops.templates = openpostFileSecretTemplates // lib.optionalAttrs isCloud { "openpost-postgres-env" = { content = '' POSTGRES_USER=${openpostPostgresUser} POSTGRES_DB=${openpostPostgresDatabase} POSTGRES_PASSWORD=${config.sops.placeholder.openpost_postgres_password} ''; mode = "0400"; restartUnits = [ "podman-openpost-postgres.service" "openpost-postgres-credential-reconcile.service" ] ++ openpostApplicationUnits; }; "openpost-cloud-env" = { content = '' OPENPOST_DATABASE_URL=postgres://${openpostPostgresUser}:${config.sops.placeholder.openpost_postgres_password}@openpost-postgres:5432/${openpostPostgresDatabase}?sslmode=disable OPENPOST_S3_ENDPOINT=${config.sops.placeholder.openpost_s3_endpoint} OPENPOST_S3_REGION=${config.sops.placeholder.openpost_s3_region} OPENPOST_S3_BUCKET=${config.sops.placeholder.openpost_s3_bucket} OPENPOST_S3_ACCESS_KEY_ID=${config.sops.placeholder.openpost_s3_access_key_id} OPENPOST_S3_SECRET_ACCESS_KEY=${config.sops.placeholder.openpost_s3_secret_access_key} OPENPOST_S3_PUBLIC_BASE_URL=${config.sops.placeholder.openpost_s3_public_base_url} OPENPOST_LEGAL_ACCEPTANCE_REQUIRED=true OPENPOST_TERMS_URL=https://openpo.st/terms OPENPOST_PRIVACY_URL=https://openpo.st/privacy OPENPOST_TERMS_VERSION=2026-08-05 OPENPOST_PRIVACY_VERSION=2026-09-01 OPENPOST_TELEMETRY_ENABLED=true OPENPOST_POSTHOG_API_HOST=https://eu.i.posthog.com OPENPOST_POSTHOG_BROWSER_HOST=https://cool.openpo.st OPENPOST_POSTHOG_UI_HOST=https://eu.posthog.com OPENPOST_TELEMETRY_ENVIRONMENT=production OPENPOST_SUPPORT_EMAIL=hello@openpo.st OPENPOST_EMAIL_VERIFICATION_REQUIRED=true OPENPOST_EMAIL_PROVIDER=smtp OPENPOST_EMAIL_FROM=hello@openpo.st OPENPOST_SMTP_HOST=smtp.purelymail.com OPENPOST_SMTP_PORT=465 OPENPOST_SMTP_USERNAME=hello@openpo.st OPENPOST_SMTP_FROM=hello@openpo.st OPENPOST_SMTP_TLS_MODE=tls OPENPOST_SMTP_SERVER_NAME=smtp.purelymail.com OPENPOST_IMAGE_CAPTION_PROVIDER=azure/eu OPENPOST_IMAGE_CAPTION_REQUIRE_ZDR=true OPENPOST_PADDLE_API_KEY=${config.sops.placeholder.openpost_paddle_api_key} OPENPOST_PADDLE_ENVIRONMENT=production OPENPOST_PADDLE_CLIENT_TOKEN=${config.sops.placeholder.openpost_paddle_client_token} OPENPOST_PADDLE_WEBHOOK_SECRET=${config.sops.placeholder.openpost_paddle_webhook_secret} OPENPOST_PADDLE_CHECKOUT_RETURN_URL=https://${cfg.domain}/checkout?status=success OPENPOST_PADDLE_STARTER_MONTHLY_PRICE_ID=pri_01kz8y75epf02dvf9yt0hcbxsr OPENPOST_PADDLE_STARTER_ANNUAL_PRICE_ID=pri_01kz8y75zmdb45ferqj6dq1s68 OPENPOST_PADDLE_FOUNDER_MONTHLY_PRICE_ID=pri_01kz8y774fgdve480x8pcd4tzq OPENPOST_PADDLE_FOUNDER_ANNUAL_PRICE_ID=pri_01kz8y77nfx8myhzjbbrnpfn5f OPENPOST_PADDLE_PRO_MONTHLY_PRICE_ID=pri_01kz8y78txwwdhbvte7gsjkpr3 OPENPOST_PADDLE_PRO_ANNUAL_PRICE_ID=pri_01kz8y79je6s27tgpgw2s6kpnb OPENPOST_PADDLE_TEAM_MONTHLY_PRICE_ID=pri_01kz8y7argrs3zygh0j73wmf9n OPENPOST_PADDLE_TEAM_ANNUAL_PRICE_ID=pri_01kz8y7b9n73r8v989skf9hbj1 OPENPOST_PADDLE_AGENCY_MONTHLY_PRICE_ID=pri_01kz8y7ccz8ve0gp2erm4yvssw OPENPOST_PADDLE_AGENCY_ANNUAL_PRICE_ID=pri_01kz8y7cy4bjsmtdtjwpwns4wf ''; mode = "0400"; restartUnits = openpostApplicationUnits; }; "openpost-backup-env" = { content = '' RCLONE_CONFIG_OPENPOST_TYPE=s3 RCLONE_CONFIG_OPENPOST_PROVIDER=Other RCLONE_CONFIG_OPENPOST_ENDPOINT=${config.sops.placeholder.openpost_s3_endpoint} RCLONE_CONFIG_OPENPOST_REGION=${config.sops.placeholder.openpost_s3_region} RCLONE_CONFIG_OPENPOST_ACCESS_KEY_ID=${config.sops.placeholder.openpost_s3_access_key_id} RCLONE_CONFIG_OPENPOST_SECRET_ACCESS_KEY=${config.sops.placeholder.openpost_s3_secret_access_key} OPENPOST_BACKUP_S3_BUCKET=${config.sops.placeholder.openpost_s3_bucket} ''; mode = "0400"; }; } // lib.optionalAttrs (isCloud && cfg.offsiteBackup.enable) { "openpost-offsite-backup-env" = { content = '' AWS_ACCESS_KEY_ID=${config.sops.placeholder.openpost_backup_s3_access_key_id} AWS_SECRET_ACCESS_KEY=${config.sops.placeholder.openpost_backup_s3_secret_access_key} AWS_DEFAULT_REGION=${config.sops.placeholder.openpost_backup_s3_region} RESTIC_REPOSITORY=s3:${config.sops.placeholder.openpost_backup_s3_endpoint}/${config.sops.placeholder.openpost_backup_s3_bucket}/openpost RESTIC_PASSWORD=${config.sops.placeholder.openpost_backup_restic_password} ''; mode = "0400"; }; }; systemd.services.openpost-image-bootstrap = lib.mkIf (cfg.bootstrapDigest != null) { description = "Seed the exact OpenPost image on a clean host"; before = openpostApplicationUnits; requiredBy = openpostApplicationUnits; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = pkgs.writeShellScript "openpost-image-bootstrap" '' set -euo pipefail image=${lib.escapeShellArg cfg.image} if ${pkgs.podman}/bin/podman image exists "$image"; then exit 0 fi candidate=${lib.escapeShellArg "${openpostImageRepository}@${cfg.bootstrapDigest}"} ${pkgs.podman}/bin/podman pull "$candidate" revision="$(${pkgs.podman}/bin/podman image inspect "$candidate" --format '{{index .Labels "org.opencontainers.image.revision"}}')" if [ "$revision" != ${lib.escapeShellArg cfg.bootstrapRevision} ]; then echo "OpenPost bootstrap image revision $revision does not match the configured revision" >&2 exit 1 fi ${pkgs.podman}/bin/podman tag "$candidate" "$image" ''; }; }; systemd.services."openpost-ops-alert@" = { description = "Send an OpenPost operations failure alert for %i"; serviceConfig = { Type = "oneshot"; ExecStart = "${openpostOpsAlert} %i"; UMask = "0077"; NoNewPrivileges = true; PrivateTmp = true; ProtectHome = true; ProtectSystem = "strict"; }; }; systemd.services.openpost-postgres-credential-reconcile = lib.mkIf isCloud { description = "Reconcile the authoritative OpenPost PostgreSQL credential"; after = [ "podman-openpost-postgres.service" ]; requires = [ "podman-openpost-postgres.service" ]; before = openpostApplicationUnits; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; serviceConfig = { Type = "oneshot"; EnvironmentFile = config.sops.templates.openpost-postgres-env.path; UMask = "0077"; ExecStart = pkgs.writeShellScript "openpost-postgres-credential-reconcile" '' set -euo pipefail for attempt in $(${pkgs.coreutils}/bin/seq 1 60); do if ${pkgs.podman}/bin/podman exec openpost-postgres pg_isready \ -U ${openpostPostgresUser} -d ${openpostPostgresDatabase} >/dev/null; then break fi if [ "$attempt" = 60 ]; then echo "OpenPost PostgreSQL did not become ready for credential reconciliation" >&2 exit 1 fi ${pkgs.coreutils}/bin/sleep 1 done encoded_password="$(printf '%s' "$POSTGRES_PASSWORD" | ${pkgs.coreutils}/bin/base64 | ${pkgs.coreutils}/bin/tr -d '\n')" printf "SELECT format('ALTER ROLE ${openpostPostgresUser} PASSWORD %%L', convert_from(decode('%s', 'base64'), 'UTF8')) \\gexec\n" "$encoded_password" \ | ${pkgs.podman}/bin/podman exec -i openpost-postgres psql \ -v ON_ERROR_STOP=1 -U ${openpostPostgresUser} -d ${openpostPostgresDatabase} >/dev/null ${pkgs.podman}/bin/podman exec --env POSTGRES_PASSWORD openpost-postgres sh -ec \ 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -v ON_ERROR_STOP=1 -U ${openpostPostgresUser} -d ${openpostPostgresDatabase} -Atqc "SELECT 1"' \ | ${pkgs.gnugrep}/bin/grep -Fx 1 >/dev/null ''; }; }; systemd.services.podman-openpost = { after = lib.optionals isCloud [ "openpost-postgres-credential-reconcile.service" ]; requires = lib.optionals isCloud [ "openpost-postgres-credential-reconcile.service" ]; serviceConfig = { ExecStop = lib.mkForce "${stopManagedContainer "openpost"}"; TimeoutStopSec = lib.mkForce 120; }; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; }; systemd.services.podman-openpost-worker = lib.mkIf isCloud { after = [ "openpost-postgres-credential-reconcile.service" ]; requires = [ "openpost-postgres-credential-reconcile.service" ]; serviceConfig = { ExecStop = lib.mkForce "${stopManagedContainer "openpost-worker"}"; TimeoutStopSec = lib.mkForce 120; }; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; }; systemd.services.podman-openpost-postgres.serviceConfig.ExecStop = lib.mkForce "${stopManagedContainer "openpost-postgres"}"; systemd.services.openpost-postgres-backup = lib.mkIf isCloud { description = "Backup OpenPost Postgres database"; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; serviceConfig = { Type = "oneshot"; UMask = "0077"; ExecStart = pkgs.writeShellScript "openpost-postgres-backup" '' set -euo pipefail timestamp=$(${pkgs.coreutils}/bin/date +%Y%m%d_%H%M%S) backup_dir=/var/backup/openpost ${pkgs.coreutils}/bin/mkdir -p "$backup_dir" backup_path="$backup_dir/openpost_$timestamp.sql.gz" backup_tmp=$(${pkgs.coreutils}/bin/mktemp "$backup_dir/.openpost_$timestamp.sql.gz.XXXXXX") cleanup() { ${pkgs.coreutils}/bin/rm -f -- "$backup_tmp" } trap cleanup EXIT ${pkgs.podman}/bin/podman exec openpost-postgres pg_dump \ -U ${openpostPostgresUser} \ -d ${openpostPostgresDatabase} | ${pkgs.gzip}/bin/gzip > "$backup_tmp" ${pkgs.gzip}/bin/gzip -t "$backup_tmp" ${pkgs.coreutils}/bin/chmod 0600 "$backup_tmp" ${pkgs.coreutils}/bin/mv "$backup_tmp" "$backup_path" trap - EXIT ${pkgs.findutils}/bin/find "$backup_dir" -name 'openpost_*.sql.gz' -mtime +14 -delete ''; }; }; systemd.timers.openpost-postgres-backup = lib.mkIf isCloud { description = "Daily OpenPost Postgres backup"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "daily"; Persistent = true; }; }; systemd.services.openpost-media-backup = lib.mkIf isCloud { description = "Backup OpenPost S3 media with retained changed and deleted objects"; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; serviceConfig = { Type = "oneshot"; UMask = "0077"; EnvironmentFile = config.sops.templates."openpost-backup-env".path; ExecStart = pkgs.writeShellScript "openpost-media-backup" '' set -euo pipefail timestamp=$(${pkgs.coreutils}/bin/date -u +%Y%m%d_%H%M%S) backup_root=/var/backup/openpost media_current="$backup_root/media-current" media_versions="$backup_root/media-versions/$timestamp" ${pkgs.coreutils}/bin/mkdir -p "$media_current" "$media_versions" ${pkgs.rclone}/bin/rclone sync \ "openpost:$OPENPOST_BACKUP_S3_BUCKET" \ "$media_current" \ --backup-dir "$media_versions" \ --fast-list \ --checkers 8 \ --transfers 4 ${pkgs.rclone}/bin/rclone check \ "openpost:$OPENPOST_BACKUP_S3_BUCKET" \ "$media_current" \ --one-way \ --size-only ${pkgs.findutils}/bin/find "$backup_root/media-versions" \ -mindepth 1 -maxdepth 1 -type d -mtime +14 \ -exec ${pkgs.coreutils}/bin/rm -rf -- {} + ''; }; }; systemd.timers.openpost-media-backup = lib.mkIf isCloud { description = "Daily OpenPost media backup"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "daily"; Persistent = true; }; }; systemd.services.openpost-offsite-backup = lib.mkIf (isCloud && cfg.offsiteBackup.enable) { description = "Encrypt and copy OpenPost backups and logs off host"; after = [ "openpost-postgres-backup.service" "openpost-media-backup.service" ]; requires = [ "openpost-postgres-backup.service" "openpost-media-backup.service" ]; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; serviceConfig = { Type = "oneshot"; UMask = "0077"; EnvironmentFile = config.sops.templates."openpost-offsite-backup-env".path; CacheDirectory = "openpost-restic"; ExecStart = pkgs.writeShellScript "openpost-offsite-backup" '' set -euo pipefail backup_root=/var/backup/openpost log_root="$backup_root/logs" evidence_root=/var/lib/openpost ${pkgs.coreutils}/bin/mkdir -p "$log_root" "$evidence_root" timestamp="$(${pkgs.coreutils}/bin/date -u +%Y%m%d_%H%M%S)" log_archive="$log_root/openpost-journal-$timestamp.json.gz" log_tmp="$(${pkgs.coreutils}/bin/mktemp "$log_root/.openpost-journal-$timestamp.XXXXXX")" cleanup() { ${pkgs.coreutils}/bin/rm -f -- "$log_tmp" } trap cleanup EXIT ${pkgs.systemd}/bin/journalctl \ --since '25 hours ago' \ --output=json \ --unit=podman-openpost.service \ --unit=podman-openpost-worker.service \ --unit=podman-openpost-postgres.service \ --unit=openpost-postgres-backup.service \ --unit=openpost-media-backup.service \ --unit=openpost-restore-drill.service \ | ${pkgs.gzip}/bin/gzip > "$log_tmp" ${pkgs.gzip}/bin/gzip -t "$log_tmp" ${pkgs.coreutils}/bin/chmod 0600 "$log_tmp" ${pkgs.coreutils}/bin/mv "$log_tmp" "$log_archive" trap - EXIT ${pkgs.findutils}/bin/find "$log_root" -type f -name 'openpost-journal-*.json.gz' -mtime +3 -delete if ! ${pkgs.restic}/bin/restic cat config >/dev/null 2>&1; then ${pkgs.restic}/bin/restic init fi snapshot_id="$(${pkgs.restic}/bin/restic backup \ --json \ --host rgo-vps \ --tag openpost \ --exclude "$backup_root/media-versions" \ "$backup_root" \ | ${pkgs.jq}/bin/jq -r 'select(.message_type == "summary") | .snapshot_id' \ | ${pkgs.coreutils}/bin/tail -n 1)" [[ "$snapshot_id" =~ ^[0-9a-f]{64}$ ]] || { echo "Restic did not report an OpenPost snapshot ID" >&2 exit 1 } ${pkgs.restic}/bin/restic check --read-data-subset=5% ${pkgs.restic}/bin/restic forget \ --host rgo-vps \ --tag openpost \ --keep-daily 7 \ --keep-weekly 5 \ --keep-monthly 12 \ --prune checked_at="$(${pkgs.coreutils}/bin/date -u +%Y-%m-%dT%H:%M:%SZ)" evidence_tmp="$(${pkgs.coreutils}/bin/mktemp "$evidence_root/.offsite-backup-latest.XXXXXX")" ${pkgs.jq}/bin/jq -cn \ --arg status passed \ --arg checked_at "$checked_at" \ --arg snapshot_id "$snapshot_id" \ '{status: $status, checked_at: $checked_at, snapshot_id: $snapshot_id}' \ > "$evidence_tmp" ${pkgs.coreutils}/bin/chmod 0600 "$evidence_tmp" ${pkgs.coreutils}/bin/mv "$evidence_tmp" "$evidence_root/offsite-backup-latest.json" ''; }; }; systemd.timers.openpost-offsite-backup = lib.mkIf (isCloud && cfg.offsiteBackup.enable) { description = "Daily encrypted off-host OpenPost backup"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "*-*-* 02:30:00"; Persistent = true; RandomizedDelaySec = "10min"; }; }; systemd.services.openpost-restore-drill = lib.mkIf isCloud { description = if cfg.offsiteBackup.enable then "Restore and validate the latest encrypted off-host OpenPost backup" else "Restore and validate the latest local OpenPost backup"; unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ]; after = [ "podman-openpost-postgres.service" ] ++ lib.optional cfg.offsiteBackup.enable "openpost-offsite-backup.service"; requires = [ "podman-openpost-postgres.service" ] ++ lib.optional cfg.offsiteBackup.enable "openpost-offsite-backup.service"; serviceConfig = { Type = "oneshot"; UMask = "0077"; RuntimeDirectory = "openpost-restore-drill"; ExecStart = pkgs.writeShellScript "openpost-restore-drill" '' set -euo pipefail backup_root=/var/backup/openpost offsite_restore_root="" database_created=false cleanup() { if [ "$database_created" = true ]; then ${pkgs.podman}/bin/podman exec openpost-postgres dropdb \ --if-exists -U ${openpostPostgresUser} "$restore_database" >/dev/null fi ${lib.optionalString cfg.offsiteBackup.enable '' if [ -n "$offsite_restore_root" ]; then ${pkgs.coreutils}/bin/rm -rf -- "$offsite_restore_root" fi ''} } trap cleanup EXIT ${ if cfg.offsiteBackup.enable then '' offsite_restore_root="$(${pkgs.coreutils}/bin/mktemp -d /run/openpost-restore-drill/offsite.XXXXXX)" ${pkgs.restic}/bin/restic restore latest \ --host rgo-vps \ --tag openpost \ --target "$offsite_restore_root" \ --include '/var/backup/openpost/openpost_*.sql.gz' \ --include '/var/backup/openpost/media-current/**' backup_source_root="$offsite_restore_root/var/backup/openpost" '' else '' backup_source_root="$backup_root" '' } latest_backup=$(${pkgs.findutils}/bin/find "$backup_source_root" -maxdepth 1 -type f -name 'openpost_*.sql.gz' -printf '%T@ %p\n' | ${pkgs.coreutils}/bin/sort -nr | ${pkgs.gawk}/bin/awk 'NR == 1 { print $2 }') if [ -z "$latest_backup" ]; then echo "No OpenPost database backup is available for the restore drill" >&2 exit 1 fi ${pkgs.gzip}/bin/gzip -t "$latest_backup" restore_database="openpost_restore_drill_$(${pkgs.coreutils}/bin/date -u +%Y%m%d_%H%M%S)" ${pkgs.podman}/bin/podman exec openpost-postgres createdb \ -U ${openpostPostgresUser} "$restore_database" database_created=true ${pkgs.gzip}/bin/gzip -dc "$latest_backup" | ${pkgs.podman}/bin/podman exec -i \ openpost-postgres psql -v ON_ERROR_STOP=1 \ -U ${openpostPostgresUser} -d "$restore_database" >/dev/null table_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'" \ -U ${openpostPostgresUser} -d "$restore_database") user_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \ "SELECT count(*) FROM users" -U ${openpostPostgresUser} -d "$restore_database") workspace_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \ "SELECT count(*) FROM workspaces" -U ${openpostPostgresUser} -d "$restore_database") post_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \ "SELECT count(*) FROM posts" -U ${openpostPostgresUser} -d "$restore_database") database_media_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \ "SELECT count(*) FROM media_attachments" -U ${openpostPostgresUser} -d "$restore_database") restored_media_root="$backup_source_root/media-current" if [ -d "$restored_media_root" ]; then media_file_count=$(${pkgs.findutils}/bin/find "$restored_media_root" -type f | ${pkgs.coreutils}/bin/wc -l) else media_file_count=0 fi if [ "$table_count" -lt 10 ]; then echo "Restore has too few public tables: $table_count" >&2 exit 1 fi if [ "$database_media_count" -gt 0 ] && [ "$media_file_count" -eq 0 ]; then echo "Restore contains media records but the media snapshot is empty" >&2 exit 1 fi checked_at=$(${pkgs.coreutils}/bin/date -u +%Y-%m-%dT%H:%M:%SZ) backup_name=$(${pkgs.coreutils}/bin/basename "$latest_backup") backup_size=$(${pkgs.coreutils}/bin/wc -c < "$latest_backup") evidence_tmp=$(${pkgs.coreutils}/bin/mktemp "$backup_root/restore-drill-latest.json.XXXXXX") { printf '{\n' printf ' "status": "passed",\n' printf ' "checked_at": "%s",\n' "$checked_at" printf ' "backup": "%s",\n' "$backup_name" printf ' "backup_bytes": %s,\n' "$backup_size" printf ' "public_tables": %s,\n' "$table_count" printf ' "users": %s,\n' "$user_count" printf ' "workspaces": %s,\n' "$workspace_count" printf ' "posts": %s,\n' "$post_count" printf ' "database_media": %s,\n' "$database_media_count" printf ' "media_files": %s\n' "$media_file_count" printf '}\n' } > "$evidence_tmp" ${pkgs.coreutils}/bin/chmod 0600 "$evidence_tmp" ${pkgs.coreutils}/bin/mv "$evidence_tmp" "$backup_root/restore-drill-latest.json" ''; } // lib.optionalAttrs cfg.offsiteBackup.enable { EnvironmentFile = config.sops.templates."openpost-offsite-backup-env".path; CacheDirectory = "openpost-restic"; }; }; systemd.timers.openpost-restore-drill = lib.mkIf isCloud { description = "Weekly OpenPost restore drill"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "Sun 04:00"; Persistent = true; }; }; vps.caddy.internalPorts.openpost = openpostHostPort; }; } ``` ### Reverse Proxy Source: [https://docs.openpo.st/installation/reverse-proxy.md](https://docs.openpo.st/installation/reverse-proxy.md) This page is for operators placing OpenPost behind a reverse proxy. HTTPS and a stable public URL matter for social network sign-in, passkeys, MCP sign-in, and public media links. #### Why it matters - Social networks check callback URLs exactly. - `OPENPOST_APP_URL` should match what users open in the browser. - `OPENPOST_PUBLIC_URL` must match the externally visible origin for passkeys and MCP metadata. - `OPENPOST_MEDIA_URL` must be public HTTPS for Threads, Facebook, Instagram, and TikTok pull-from-URL publishing. #### Required app settings - `OPENPOST_APP_URL=https://openpost.example.com` - `OPENPOST_PUBLIC_URL=https://openpost.example.com` - `OPENPOST_MEDIA_URL=https://openpost.example.com/media` #### Caddy example ```txt openpost.example.com { reverse_proxy localhost:8080 } ``` #### Nginx example ```nginx server { listen 443 ssl http2; server_name openpost.example.com; client_max_body_size 16G; location / { proxy_pass http://127.0.0.1:8080; proxy_request_buffering off; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` #### Large video uploads Set the proxy and any upstream CDN request-body limit to the largest video size you plan to accept. X subscribed accounts can use up to 16 GiB. Keep request buffering disabled so the proxy does not write a complete multi-gigabyte upload to temporary storage before OpenPost receives it. The Nginx example above shows both settings; Caddy does not impose a request-body limit unless you configure one. S3-compatible deployments send files up to 5 GB directly to the bucket. Larger files and local-storage uploads pass through the reverse proxy as an authenticated stream. See [Media Storage](https://docs.openpo.st/configuration/media-storage.md) for the full flow. #### Social network callback URLs Update your social network apps to use your public domain: - `https://openpost.example.com/api/v1/accounts/x/callback` - `https://openpost.example.com/api/v1/accounts/linkedin/callback` - `https://openpost.example.com/api/v1/accounts/threads/callback` - `https://openpost.example.com/api/v1/accounts/facebook/callback` - `https://openpost.example.com/api/v1/accounts/instagram/callback` - `https://openpost.example.com/api/v1/accounts/tiktok/callback` - `https://openpost.example.com/api/v1/accounts/youtube/callback` Mastodon uses `urn:ietf:wg:oauth:2.0:oob` by default, so you usually do not add a Mastodon callback URL unless you override `MASTODON_REDIRECT_URI`. #### Social networks that fetch media Threads, Facebook, Instagram, and TikTok need to open the media link over public HTTPS. TikTok also requires proof that you own the link prefix or domain. If `OPENPOST_MEDIA_URL` points to a private host or local path, OpenPost blocks the post before it contacts the social network. #### Subpaths such as `https://example.com/openpost/` **Not supported.** The SvelteKit frontend is built with `@sveltejs/adapter-static` and the Go binary embeds the resulting `build/` directory. Asset URLs (`/_app/...`, `/sw.js`, `/manifest.webmanifest`, and others) start at the root. Sign-in callback links also assume that OpenPost is served from the root. Run OpenPost on its own subdomain, such as `https://openpost.example.com`. ## Self-hosting ### Self-Hosting Docs Source: [https://docs.openpo.st/self-hosting/index.md](https://docs.openpo.st/self-hosting/index.md) Use these docs when you operate OpenPost on your own infrastructure. Self-hosting has no software fee, but it is not a Hosted service plan or a zero-price tier of the Hosted service. You pay for your infrastructure and any provider or third-party services you use. These pages assume that you manage the server, public HTTPS origin, access controls, secrets, database, media storage, monitoring, backups, upgrades, and incident response. You also support the people who use your deployment and maintain its privacy, retention, and recovery practices. #### Responsibility boundary - **Infrastructure and data:** you choose and secure the server, database, media storage, logs, network, and data locations. - **Upgrades and backups:** you track releases and security notices, apply updates, back up the database, media, and required secrets together, and test restores. - **Provider projects:** you create and maintain social network projects, callback URLs, permissions, reviews, API access, and provider budgets. OpenPost still sends requested content and access tokens to those networks. - **Support:** OpenPost publishes documentation, source code, a public issue tracker, and community help. A self-hosted operator handles deployment availability, user support, incidents, and operator-specific configuration. Review the OpenPost source, security policy, and current releases before exposing an instance to users. #### When self-hosting fits A self-hosted installation stores OpenPost data on servers and storage you choose: - drafts, schedules, workspaces, and publishing history in SQLite or PostgreSQL; - media on local disk or S3-compatible object storage; - encrypted social account tokens in the app database; - app secrets, social app keys, logs, backups, and data retention settings. OpenPost still sends content and access tokens to each social network when it carries out your request. Self-hosting changes who runs OpenPost. It does not remove the social networks. Plan for TLS and a public application URL, unique JWT and encryption secrets, tested database and media restores, release and security updates, social app keys and reviews, and checks for failed posts and low storage. The default setup stays small: one Go binary or container, SQLite, local media, and saved background jobs. Redis is not required. PostgreSQL and S3-compatible storage are available for larger setups. Choose self-hosting when you already operate a server, need data in a specific environment, want to inspect or change the code, or need custom storage, networking, or social app settings. Choose the Hosted service when you want OpenPost to manage its server, backups, TLS, and upgrades. #### Install - [Docker Compose](https://docs.openpo.st/installation/docker-compose.md) is the best place to start. - [Single Binary](https://docs.openpo.st/installation/binary.md) covers the app and server in one file. - [Nix Module](https://docs.openpo.st/installation/nix-module.md) covers the generated NixOS module reference. - [Reverse Proxy](https://docs.openpo.st/installation/reverse-proxy.md) covers public HTTPS routing. - [Build From Source](https://docs.openpo.st/installation/build-from-source.md) covers local builds. - [Docker Run](https://docs.openpo.st/installation/docker-run.md) shows how to run the container by hand. #### Configure - [Configuration](https://docs.openpo.st/configuration/index.md) groups the main settings. - [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md) is the full configuration reference. - [Custom Connectors](https://docs.openpo.st/configuration/custom-connectors.md) adds operator-run HTTP services as publishing destinations. - [Database](https://docs.openpo.st/configuration/database.md) covers SQLite and Postgres. - [Media Storage](https://docs.openpo.st/configuration/media-storage.md) covers local files and S3/R2 storage. - [CORS and URLs](https://docs.openpo.st/configuration/cors-and-urls.md) covers public app and media web addresses. - [Production Checklist](https://docs.openpo.st/configuration/production-checklist.md) helps you check the server before people use it. #### Providers - [Providers](https://docs.openpo.st/providers/index.md) explains social network app setup, implementations, and limits. - [Provider Troubleshooting](https://docs.openpo.st/providers/troubleshooting.md) helps with OAuth, access, media link, and publish errors. - [Provider Launch Matrix](https://docs.openpo.st/operations/provider-launch-matrix.md) covers evidence and readiness for operators. - [X](https://docs.openpo.st/providers/x.md), [Mastodon](https://docs.openpo.st/providers/mastodon.md), [Bluesky](https://docs.openpo.st/providers/bluesky.md), [LinkedIn](https://docs.openpo.st/providers/linkedin.md), [Threads](https://docs.openpo.st/providers/threads.md), [Facebook](https://docs.openpo.st/providers/facebook.md), [Instagram](https://docs.openpo.st/providers/instagram.md), [TikTok](https://docs.openpo.st/providers/tiktok.md), [YouTube](https://docs.openpo.st/providers/youtube.md), and [Discord](https://docs.openpo.st/providers/discord.md) cover each network's setup. #### Operate - [Backups](https://docs.openpo.st/operations/backups.md) - [Health Checks](https://docs.openpo.st/operations/health-checks.md) - [Logs](https://docs.openpo.st/operations/logs.md) - [Upgrades](https://docs.openpo.st/operations/upgrades.md) - [Troubleshooting](https://docs.openpo.st/operations/troubleshooting.md) #### Adjacent docs - If you only need to use the product, start with [User Docs](https://docs.openpo.st/usage/index.md). - If you are changing OpenPost code, start with [Developer Docs](https://docs.openpo.st/development/index.md). ## Configuration ### CORS And URLs Source: [https://docs.openpo.st/configuration/cors-and-urls.md](https://docs.openpo.st/configuration/cors-and-urls.md) This page is for operators configuring public URLs and allowed browser origins. These settings solve many deployment problems when they are set correctly. #### `OPENPOST_APP_URL` The public URL users visit in the browser. This is also part of the default CORS allowlist. #### `OPENPOST_EXTRA_CORS_ORIGINS` Extra origins to allow, as a comma-separated list. Use this if you have alternate domains, admin origins, or a separate dev frontend. Self-hosted mode also allows local browser development origins by default. Cloud mode is stricter: it allows `OPENPOST_APP_URL` and explicit extra origins only, and rejects wildcard `*` origins at startup. The native Expo app calls the HTTP API directly and does not need a browser CORS origin. #### `OPENPOST_MEDIA_URL` The public base URL for uploaded media. Threads, Facebook, Instagram, and some TikTok posts need it. The default `/media` path is resolved against `OPENPOST_PUBLIC_URL`, then `OPENPOST_APP_URL`. Override it with an absolute URL when media is served from a separate public origin. #### Provider callback URLs These are configured in the provider developer portals and should point back to your public OpenPost domain. They are separate from browser CORS settings. #### Common mistakes - `OPENPOST_APP_URL` still points at localhost in production - Hosted/cloud mode relies on implicit localhost CORS origins instead of explicit `OPENPOST_EXTRA_CORS_ORIGINS` - `OPENPOST_EXTRA_CORS_ORIGINS` contains `*` while credentials are enabled - `OPENPOST_MEDIA_URL` points at an internal hostname that a social network cannot open - Provider callback URLs still use the local development domain - Reverse proxy serves a different hostname than the one configured in OAuth apps ### Custom connectors Source: [https://docs.openpo.st/configuration/custom-connectors.md](https://docs.openpo.st/configuration/custom-connectors.md) For self-hosted operators installing publishing destinations that OpenPost does not ship with. Custom connectors add publishing destinations that OpenPost does not ship with. They work only on self-hosted instances. A connector is a service that you install and operate beside OpenPost. OpenPost calls it through Connector Protocol 1.0 over authenticated HTTP/JSON or a Unix socket. OpenPost never loads connector code into its process. Protocol 1.0 supports: - text posts - connections configured by the instance operator - destination-specific text fields and settings - immediate or pending publish results It does not yet support media, threads, browser automation, OAuth or form-based connection flows, analytics, comments, inboxes, or connector-supplied UI. The [Directus walkthrough](https://docs.openpo.st/configuration/custom-connectors.md#publish-to-directus) below uses the connector included in the repository. To write another connector, read [Connector Protocol 1.0](https://docs.openpo.st/development/connector-protocol.md). #### How the parts divide the work OpenPost owns the Publication, Rendition, schedule, validation, job, access check, write fence, retry policy, and visible history. The connector owns its destination credentials, destination API calls, and duplicate-write checks. The browser never receives the connector URL, bearer token, or destination credentials. Workspace members can connect only the installations that the instance operator has registered for their Workspace. #### Install a connector 1. Run a service that implements Connector Protocol 1.0. 2. Create a bearer token and store it in a secret file that both services can read. 3. Create the connector registry on the OpenPost host. 4. Set `OPENPOST_CONNECTORS_FILE` to the registry's absolute path. 5. Restart OpenPost. 6. Check the startup log. Fix or remove any quarantined connector. 7. Open **Social accounts** in a Workspace and connect the custom destination. OpenPost reads the registry only at startup. Restart it after changing an installation, endpoint, allowlist, or token. #### Connector registry The registry uses strict JSON. Unknown fields, duplicate installation IDs, relative secret paths, and unsupported versions make the file invalid. ```json { "version": 1, "installations": [ { "id": "directus-main", "required": false, "workspace_allowlist": ["workspace-id"], "endpoint": { "mode": "private_allowlist", "base_url": "http://directus-connector:8787", "allowed_hosts": ["directus-connector"], "allowed_cidrs": ["172.16.0.0/12"], "allowed_ports": [8787] }, "auth": { "bearer_token_file": "/run/secrets/openpost-connector-token" } } ] } ``` | Field | Meaning | | --------------------- | ------------------------------------------------------------------------------------------ | | `version` | Registry format. The current value is `1`. | | `id` | One configured connector installation. Use a stable, unique value. | | `required` | If `true`, an invalid or unavailable connector blocks OpenPost startup. | | `workspace_allowlist` | Exact Workspace IDs allowed to use the installation. An empty list allows every Workspace. | | `endpoint` | One operator-controlled network policy. Workspace and post data cannot change it. | | `bearer_token_file` | Absolute path to one token. The registry cannot contain an inline token. | An installation ID identifies one configured service. The provider ID in the connector manifest identifies the connector type. Two installations may use the same provider ID, such as two Directus sites, but their installation IDs must differ. A connector cannot use the ID of a built-in provider. #### Choose an endpoint mode Use the narrowest mode that fits the deployment. - `public_https`: for a public HTTPS address. Set `base_url`. - `private_allowlist`: for a private host or container network. Set `base_url`, exact `allowed_hosts`, `allowed_cidrs`, and `allowed_ports`. - `unix_socket`: for two services on one host. Set an absolute `socket_path`. For `public_https`, OpenPost rejects private, loopback, link-local, and reserved addresses. It checks DNS again when it connects. For `private_allowlist`, every resolved address and port must match the registry. OpenPost also blocks redirects, ignores proxy environment variables, limits responses to 1 MiB, and applies request timeouts. Do not expose a private connector port to the public internet. Use HTTPS for any connector traffic that leaves a trusted host or private network. #### Connect it to a Workspace After OpenPost accepts the connector at startup: 1. Open the target Workspace. 2. Go to **Social accounts**. 3. Find the custom connector card and select **Connect**. 4. Choose the returned destination account if the connector offers more than one. Protocol 1.0 connections complete at once. The connector may return one or more preconfigured destination accounts, but it cannot ask the user for a URL, token, or custom form value. #### Change or remove an installation Keep the installation ID and provider ID stable across updates. Change `capability_revision` when a connector changes its output rules, settings, or publish behavior. OpenPost then blocks old account bindings until someone reconnects them. Removing or disabling an installation does not delete its connected accounts, Publications, Renditions, or delivery history. It stops new connections and publishes. Add the same installation back to restore access. Set `required` to `true` only when the instance must not start without that connector. A failed optional connector enters quarantine while the rest of OpenPost starts. #### Publish to Directus The Directus reference connector creates one collection item for each text Rendition. OpenPost talks to the connector, and the connector talks to Directus. OpenPost never receives the Directus token. The source lives in `examples/connectors/directus`. ##### Create the collection Create a Directus collection, such as `posts`, with these fields: - `title`: an optional string for the post title - `content`: required text for the post body - `description`: optional text for the description - `status`: a string that allows `draft` and `published` - `openpost_operation_id`: a required, unique string that users cannot edit The unique operation field prevents duplicate items. Before each create request, the connector looks for an item with the same OpenPost operation ID. If Directus reports a conflict, the connector checks again and returns the existing item. Create a Directus access token that can read items by `openpost_operation_id` and create items in this collection. Do not give it access to unrelated collections or administrator settings. ##### Create the secret files Create two different tokens: - a connector bearer token used between OpenPost and the connector - the Directus access token used between the connector and Directus Store each token as one line in a secret file. Both services must read the same connector bearer token. Only the connector should read the Directus token. Do not put either token in the registry, Compose file, shell history, container image, or repository. ##### Run the connector For a local source checkout: ```bash cd examples/connectors/directus export CONNECTOR_BEARER_TOKEN_FILE=/run/secrets/openpost-connector-token export DIRECTUS_URL=https://cms.example.com export DIRECTUS_TOKEN_FILE=/run/secrets/directus-token export DIRECTUS_COLLECTION=posts go run . ``` The service listens on `127.0.0.1:8787` by default. In a container, set `CONNECTOR_LISTEN_ADDRESS=:8787` and keep the port on a private network shared with OpenPost. Build the included image with: ```bash docker build -t openpost-directus-connector examples/connectors/directus ``` Use HTTPS for Directus in production. `DIRECTUS_ALLOW_HTTP=true` permits HTTP only for local development. ##### Register and connect it Use the registry example above or copy `openpost-connectors.example.json`. The sample expects a container named `directus-connector` at `http://directus-connector:8787`. Inspect the container network used by OpenPost and the connector. Add only that range to `allowed_cidrs`; do not copy the sample range without checking it. Set `OPENPOST_CONNECTORS_FILE`, restart OpenPost, then connect the **Directus** card in **Social accounts**. Publish a text post and confirm that Directus contains one item with a non-empty `openpost_operation_id`. In a test instance, repeat a publish request with that operation ID and confirm that Directus still contains one item. ##### Change the field names Use these variables if your collection uses other field names: - `DIRECTUS_CONTENT_FIELD`, default `content` - `DIRECTUS_TITLE_FIELD`, default `title` - `DIRECTUS_DESCRIPTION_FIELD`, default `description` - `DIRECTUS_STATUS_FIELD`, default `status` - `DIRECTUS_OPERATION_FIELD`, default `openpost_operation_id` Set `DIRECTUS_ITEM_URL_TEMPLATE`, such as `https://cms.example.com/admin/content/posts/{id}`, to add a link to the Directus item in OpenPost. If the connector enters quarantine, check DNS, the private network allowlist, the connector token, Directus health, and the Directus token. If a retry creates a second item, stop publishing and confirm that `DIRECTUS_OPERATION_FIELD` points to a unique field. #### Before production use - Store connector and destination tokens in secret files. - Restrict the installation to the Workspaces that need it. - Restrict the endpoint to its exact host, network range, and port. - Confirm that the connector records each `operation_id` before or with its first destination write. - Test a timeout after the destination accepts a write. The same operation must not create a second item. - Back up any connector-owned operation journal with the destination data it protects. - Keep the connector running while scheduled posts can reach it. See [Connector Protocol 1.0](https://docs.openpo.st/development/connector-protocol.md) for the route contract and publish safety rules. ### Database Source: [https://docs.openpo.st/configuration/database.md](https://docs.openpo.st/configuration/database.md) OpenPost uses SQLite by default when you run it yourself. Hosted installs can use Postgres by changing the database driver. #### Default path The backend code defaults to: ```txt file:openpost.db?cache=shared&mode=rwc ``` For container deployments, prefer an explicit file path such as: ```txt /data/db/openpost.db ``` #### Operational notes - Persist the database on durable storage. - Back up the database together with the media directory. - Do not keep the database inside ephemeral container layers. - SQLite is configured for a simple single-node deployment model. #### Driver settings ```sh OPENPOST_DATABASE_DRIVER=sqlite OPENPOST_DATABASE_PATH=file:openpost.db?cache=shared&mode=rwc ``` For Postgres-backed deployments: ```sh OPENPOST_DATABASE_DRIVER=postgres OPENPOST_DATABASE_URL=postgres://openpost:secret@db.internal:5432/openpost?sslmode=require ``` Postgres connection pools use explicit per-process limits. The combined single-process role allows 20 open connections, web allows 16, worker allows 8, and migration allows 2. Count these limits across every replica when sizing the Postgres service. OpenPost logs the effective budget at startup. Every Postgres process also reports new connection waits or a transition into pool saturation through standard output. #### Cloud mode When `OPENPOST_EDITION=cloud`, OpenPost refuses to start unless: - `OPENPOST_DATABASE_DRIVER=postgres` - `OPENPOST_DATABASE_URL` is set This stops a hosted server from starting with a local SQLite file by mistake. SQLite remains the recommended choice for a small self-hosted server. ### Environment Variables Source: [https://docs.openpo.st/configuration/environment-variables.md](https://docs.openpo.st/configuration/environment-variables.md) This reference is for operators configuring an OpenPost instance. This page summarizes the env vars used by the backend. Some values in `.env.example` are recommended deployment examples; code defaults may differ. #### File-backed values Most variables loaded through the main backend config loader can also be loaded from `_FILE`. OpenPost checks the direct variable first, then its file variant, then any legacy aliases and their file variants. File contents are trimmed before use. Adapter-only variables read directly by provider code, such as `META_GRAPH_API_VERSION`, do not currently support `_FILE` variants. This is useful for Docker, Podman, Kubernetes, NixOS, and sops-managed secrets: ```sh OPENPOST_JWT_SECRET_FILE=/run/secrets/openpost-jwt-secret OPENPOST_ENCRYPTION_KEY_FILE=/run/secrets/openpost-encryption-key OPENPOST_DATABASE_URL_FILE=/run/secrets/openpost-database-url ``` Leave the direct variable unset when you want the file value to win. #### Admin-managed optional settings Instance administrators can manage account policy, Paddle billing, authentication, email, OpenPost Image Editor, stock media, feedback, provider behavior, and provider-app settings in **Settings → Instance → Configuration**. OpenPost encrypts every database-backed value with `OPENPOST_ENCRYPTION_KEY`; secret values are write-only and the API only reports whether one exists. For these administrator-managed values, except provider apps, configuration precedence is: 1. an encrypted administrator override in the database; 2. a direct environment variable or its `_FILE` variant; 3. the documented application default. When an environment value already exists, the screen names its variable and clearly labels both a pending override and an active override. The environment value stays configured as the fallback but is not returned separately by the API. Database changes are validated and saved together, then take effect after the next server restart. Removing an administrator override returns the setting to its environment value or default after restart. Bootstrap and data-plane settings stay deployment-only because OpenPost needs them before it can read the database: edition, port, database driver and DSN, public application origins and CORS, `OPENPOST_JWT_SECRET`, `OPENPOST_ENCRYPTION_KEY`, and storage. Paddle billing values can come from either the deployment environment or the encrypted instance-admin registry; configure the complete required set before restarting a cloud instance. Provider apps keep their separate environment-first precedence, as described below. Adapter-only variables such as `META_GRAPH_API_VERSION` also remain deployment-only. #### Core settings | Variable | Required | Default | Description | | ---------------------------------------- | ------------------------------------: | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OPENPOST_PORT` | No | `8080` | HTTP server port. | | `OPENPOST_EDITION` | No | `selfhost` | Product edition. Valid values: `selfhost`, `cloud`. Cloud mode enforces hosted storage requirements at startup. | | `OPENPOST_DATABASE_DRIVER` | Required as `postgres` in cloud mode | `sqlite` | Database driver. Valid values: `sqlite`, `postgres`. SQLite remains the self-hosted default. | | `OPENPOST_DATABASE_PATH` | No | `file:openpost.db?cache=shared&mode=rwc` | SQLite database path or DSN. Also acts as a legacy fallback DSN for Postgres if `OPENPOST_DATABASE_URL` is unset. | | `OPENPOST_DATABASE_URL` | Required for Postgres and cloud mode | empty | Postgres connection URL used when `OPENPOST_DATABASE_DRIVER=postgres`. | | `OPENPOST_APP_URL` | No, but set it in real deployments | `http://localhost:8080` | Public frontend origin used for CORS and auth flow assumptions. | | `OPENPOST_PUBLIC_URL` | No | falls back to `OPENPOST_APP_URL` | Canonical browser origin used when configuring WebAuthn/passkeys. Set this to your real app URL in production. | | `OPENPOST_EXTRA_CORS_ORIGINS` | No | empty | Extra comma-separated origins to allow. Cloud mode allows only `OPENPOST_APP_URL` plus these explicit origins and rejects `*`. | | `OPENPOST_DISABLE_REGISTRATIONS` | No | `false` | Disables new self-service signups after setup. The first account on a fresh instance is still allowed and becomes the instance admin automatically. | | `OPENPOST_PUBLIC_PROFILES_ENABLED` | No | `true` | Allows users to publish username-based profiles. Set `false` to disable the public API and return `404` from direct profile pages. | | `OPENPOST_LEGAL_ACCEPTANCE_REQUIRED` | Required in cloud mode | `false` for self-host, `true` for cloud | Requires explicit registration acceptance of the configured Terms and Privacy Policy versions. | | `OPENPOST_TERMS_URL` | Required in cloud mode | hosted URL in cloud, empty otherwise | Public Terms of Service URL shown during registration. | | `OPENPOST_PRIVACY_URL` | Required in cloud mode | hosted URL in cloud, empty otherwise | Public Privacy Policy URL shown during registration. | | `OPENPOST_TERMS_VERSION` | Required in cloud mode | `2026-08-05` in cloud, empty otherwise | Version stored on the user record when the terms are accepted. | | `OPENPOST_PRIVACY_VERSION` | Required in cloud mode | `2026-09-01` in cloud, empty otherwise | Version stored on the user record when the privacy policy is acknowledged. | | `OPENPOST_SUPPORT_EMAIL` | Required in cloud mode | `openpost@rgo.pt` in cloud, empty otherwise | Support contact shown when password recovery is unavailable. | | `OPENPOST_EMAIL_VERIFICATION_REQUIRED` | Required in cloud mode | `false` for self-host, `true` for cloud | Requires a new email-and-password account to confirm a six-digit code before OpenPost creates a session. Existing users are marked verified during migration. | | `OPENPOST_EMAIL_PROVIDER` | No | inferred when possible | Optional mail transport for authentication and user notifications: `smtp`, `resend`, or `cloudflare`. When verification is required but no transport is ready, password registration is unavailable until an admin configures one. | | `OPENPOST_EMAIL_FROM` | Required when a mail provider is set | falls back to `OPENPOST_SMTP_FROM` | Verified sender used for signup codes, password resets, Transactional Workspace invitations, and user notifications. | | `OPENPOST_TELEMETRY_ENABLED` | Required in cloud mode | `false` for self-host, `true` for cloud | Makes privacy-limited PostHog browser choices available and enables separate service and error telemetry after restart. Self-hosted operators must opt in explicitly. | | `OPENPOST_POSTHOG_PROJECT_TOKEN` | Required when telemetry is enabled | empty | Browser-safe write token for the operator-owned PostHog project. This is not a personal API key. | | `OPENPOST_POSTHOG_API_HOST` | Required when telemetry is enabled | empty | Direct PostHog ingestion endpoint used by the backend, such as `https://eu.i.posthog.com`. | | `OPENPOST_POSTHOG_BROWSER_HOST` | No | API host for self-hosting, Hosted service proxy | Browser ingestion endpoint or first-party reverse proxy. Cloud mode defaults to `https://cool.openpo.st`; an explicit value overrides that default. | | `OPENPOST_POSTHOG_UI_HOST` | Required with browser proxy | empty for self-host, EU UI for cloud | Real PostHog application host, such as `https://eu.posthog.com`, used separately from an ingestion proxy. | | `OPENPOST_TELEMETRY_ENVIRONMENT` | No | `selfhost` or `production` by edition | Stable environment label attached to events. Use separate PostHog projects for production, staging, and development. | | `OPENPOST_RESEND_API_KEY` | Required for the Resend transport | empty | Resend API key. Supports `OPENPOST_RESEND_API_KEY_FILE`. | | `OPENPOST_CLOUDFLARE_EMAIL_ACCOUNT_ID` | Required for the Cloudflare transport | empty | Cloudflare account ID for Email Service. | | `OPENPOST_CLOUDFLARE_EMAIL_API_TOKEN` | Required for the Cloudflare transport | empty | Cloudflare API token with Email Sending permission. Supports `OPENPOST_CLOUDFLARE_EMAIL_API_TOKEN_FILE`. | | `OPENPOST_EMAIL_DELIVERY_WEBHOOK_SECRET` | No | empty | HMAC secret for delivery callbacks at `POST /api/v1/email/delivery/webhook`. Supports `OPENPOST_EMAIL_DELIVERY_WEBHOOK_SECRET_FILE`. Leave empty when the email transport cannot send delivery callbacks. | | `OPENPOST_SMTP_HOST` | Required for the SMTP transport | empty | SMTP host used for authentication, Transactional Workspace invitations, and user notification mail. | | `OPENPOST_SMTP_PORT` | No | `587` | SMTP port. Use 587 with STARTTLS or 465 with implicit TLS. | | `OPENPOST_SMTP_USERNAME` | No | empty | SMTP authentication username. | | `OPENPOST_SMTP_PASSWORD` | Required when SMTP username is set | empty | SMTP authentication password. Supports `OPENPOST_SMTP_PASSWORD_FILE`. | | `OPENPOST_SMTP_FROM` | No | empty | Legacy SMTP sender fallback for `OPENPOST_EMAIL_FROM`. | | `OPENPOST_SMTP_TLS_MODE` | No | `starttls` | `starttls`, `tls`, or `none`. Plaintext mode is rejected for non-loopback hosts. | | `OPENPOST_SMTP_SERVER_NAME` | No | SMTP hostname | Optional TLS server-name override. | | `OPENPOST_JWT_SECRET` | Yes | none | Secret used to sign JWTs. Must be at least 32 characters. | | `OPENPOST_ENCRYPTION_KEY` | Yes | none | Secret used to encrypt stored OAuth tokens. Must be at least 32 characters. | | `OPENPOST_AUTH_GOOGLE_CLIENT_ID` | No | empty | Google OAuth client ID. Enables first-party Google sign-in and account linking when paired with its secret. | | `OPENPOST_AUTH_GOOGLE_CLIENT_SECRET` | Required when the Google client ID is configured | empty | Google OAuth client secret. Prefer `OPENPOST_AUTH_GOOGLE_CLIENT_SECRET_FILE` in production. | | `OPENPOST_OIDC_ISSUER` | No | empty | Exact issuer for the optional instance-wide OIDC provider. Private network issuers are allowed because this is trusted operator configuration. | | `OPENPOST_OIDC_CLIENT_ID` | Required when OIDC issuer is set | empty | Client ID for the instance-wide OIDC provider. | | `OPENPOST_OIDC_CLIENT_SECRET` | No | empty | OIDC client secret. Prefer `OPENPOST_OIDC_CLIENT_SECRET_FILE` in production. | | `OPENPOST_OIDC_NAME` | No | `Single sign-on` | Provider label shown on the login page. | | `OPENPOST_OIDC_SCOPES` | No | `openid profile email` | Space- or comma-separated OIDC scopes. OpenPost always includes `openid`. | | `OPENPOST_OIDC_JIT_ENABLED` | No | `false` | Creates a passwordless local user after a verified first login. Existing accounts with the same email are never linked automatically. | | `OPENPOST_OIDC_BOOTSTRAP_ALLOWLIST` | No | empty | Comma-separated exact issuer-and-subject pairs or emails that may become instance admin during environment-provider JIT. | | `OPENPOST_SSO_BREAK_GLASS_EMAILS` | No | empty | Existing instance admins that may bypass required workspace SSO. Each account must already have a local password and TOTP or passkey. | | `OPENPOST_OIDC_NATIVE_CALLBACK_URL` | No | `openpost://oidc/callback` | Universal or app link that receives an opaque, one-time native handoff code. It never receives an OpenPost JWT. | | `OPENPOST_STORAGE_DRIVER` | Required as `s3` in cloud mode | `local` | Media storage driver. Valid values: `local`, `s3`. | | `OPENPOST_MEDIA_PATH` | No | `./media` | Local directory for uploaded media. | | `OPENPOST_MEDIA_URL` | No | `/media`, resolved against the public app URL | Public base URL for media files. Set an absolute URL only for a separate media origin or path. | | `OPENPOST_S3_ENDPOINT` | Required for R2 or non-AWS S3-compatible storage | empty | S3-compatible API endpoint. Native AWS S3 can leave this empty. | | `OPENPOST_S3_REGION` | Required for S3-compatible storage and cloud mode | empty | S3 region. R2 commonly uses `auto`. | | `OPENPOST_S3_BUCKET` | Required for S3-compatible storage and cloud mode | empty | Bucket name for uploaded media. | | `OPENPOST_S3_ACCESS_KEY_ID` | Required for S3-compatible storage and cloud mode | empty | S3 access key ID. | | `OPENPOST_S3_SECRET_ACCESS_KEY` | Required for S3-compatible storage and cloud mode | empty | S3 secret access key. | | `OPENPOST_S3_PUBLIC_BASE_URL` | Required in cloud mode | empty | Public media base URL for provider fetches and preview links. | | `OPENPOST_S3_FORCE_PATH_STYLE` | No | `false` | Force path-style S3 addressing for compatible providers that require it. | | `OPENPOST_IMAGE_EDITOR_ENABLED` | No | `true` | Enables OpenPost Image Editor routes and APIs. Set `false` for an operational rollback; Media upload and library features remain available and OpenPost Image Editor migrations still run. | | `OPENPOST_IMAGE_EDITOR_MODEL_BASE_URL` | No | `/image-editor-models` | Base URL for the background-removal model, WASM, and runtime assets. Leave unset to use the files embedded with OpenPost. | | `OPENPOST_STOCK_MEDIA_ENABLED` | No | `false` | Enables rate-limited stock search for providers that also have a configured server-side key. | | `OPENPOST_PEXELS_API_KEY` | No | empty | Server-only Pexels API key for photo and video search. | | `OPENPOST_UNSPLASH_ACCESS_KEY` | No | empty | Server-only Unsplash access key for photo search and required selection tracking. | | `OPENPOST_PIXABAY_API_KEY` | No | empty | Server-only Pixabay API key for image and video search. | | `OPENROUTER_API_KEY` | No | empty | Server-only OpenRouter key that enables AI post building, meme suggestions, and automatic image alt text. Supports `OPENROUTER_API_KEY_FILE`. | | `OPENPOST_CONTENT_AI_PROVIDER` | No | image-caption provider | Exact OpenRouter provider slug allowed for post building and AI meme suggestions. The Hosted service requires `azure/eu`. | | `OPENPOST_CONTENT_AI_REQUIRE_ZDR` | No | image-caption ZDR policy | Require OpenRouter to use zero-data-retention endpoints for post building and AI meme suggestions. The Hosted service requires `true`. | | `OPENPOST_IMAGE_CAPTION_MODEL` | No | `openai/gpt-5.6-luna` | OpenRouter model ID used for automatic image alt text. | | `OPENPOST_TEXT_GENERATION_MODEL` | No | `openai/gpt-5.6-luna` | OpenRouter model ID used to build publication copy from an idea. | | `OPENPOST_IMAGE_CAPTION_PROVIDER` | No | empty | Exact OpenRouter provider slug allowed for automatic image alt text. An empty value uses normal eligible-provider routing. | | `OPENPOST_IMAGE_CAPTION_REQUIRE_ZDR` | No | `false` | Require OpenRouter to use a zero-data-retention endpoint for automatic image alt text. Verify the configured model/provider pair supports ZDR before enabling. | | `OPENPOST_MEME_GENERATOR_ENABLED` | No | `true` | Enables the built-in template catalog, local previews and rendering, and durable OpenPost recipes. | | `OPENPOST_MEME_GENERATION_MODEL` | No | `openai/gpt-5.6-luna` | OpenRouter model used for optional meme template and caption suggestions. | | `OPENPOST_FEEDBACK_ENABLED` | No | `false` | Shows the authenticated feedback form only when a valid destination and recipient are also configured. | | `OPENPOST_FEEDBACK_DESTINATION_URL` | Required when feedback is enabled | empty | Server-only HTTPS Discord-compatible webhook. Use `OPENPOST_FEEDBACK_DESTINATION_URL_FILE` for a managed secret. | | `OPENPOST_FEEDBACK_RECIPIENT` | Required when feedback is enabled | empty | Plain recipient name shown to users before they send a report, such as `OpenPost team` or `Example operator`. | | `OPENPOST_FEEDBACK_SUPPORT_URL` | No | OpenPost GitHub new-issue URL | HTTPS support link shown when the report form is disabled. Query strings and fragments are removed. | | `OPENPOST_UPDATE_CHECK_ENABLED` | No | `true` | Enables the read-only stable release check for self-hosted instance admins. Cloud mode never checks. | | `OPENPOST_PADDLE_API_KEY` | Required in cloud mode | empty | Server-only Paddle API key used to reconcile customers and subscriptions and create portal sessions. Backend-only; set `OPENPOST_PADDLE_*` in the backend process environment. Bare `PADDLE_*` is ignored and triggers a startup warning with the ignored names (no values). | | `OPENPOST_PADDLE_ENVIRONMENT` | Required in cloud mode | empty | Explicit Paddle environment: `sandbox` or `production`. API-key and client-token prefixes must match. Backend-only; bare `PADDLE_*` is ignored. | | `OPENPOST_PADDLE_CLIENT_TOKEN` | Required in cloud mode | empty | Browser-safe Paddle.js client token used for localized price previews and checkout. Backend-only. | | `OPENPOST_PADDLE_WEBHOOK_SECRET` | Required in cloud mode | empty | Paddle notification destination secret used to verify raw webhook requests. Backend-only. | | `OPENPOST_PADDLE_CHECKOUT_RETURN_URL` | No | `/checkout?status=success` | OpenPost success URL supplied to Paddle checkout. Backend-only. | | `OPENPOST_PADDLE_STARTER_MONTHLY_PRICE_ID` | Required in cloud mode | empty | Paddle Starter monthly price ID. | | `OPENPOST_PADDLE_STARTER_ANNUAL_PRICE_ID` | Required in cloud mode | empty | Paddle Starter annual price ID. | | `OPENPOST_PADDLE_FOUNDER_MONTHLY_PRICE_ID` | Required in cloud mode | empty | Paddle Founder monthly price ID. | | `OPENPOST_PADDLE_FOUNDER_ANNUAL_PRICE_ID` | Required in cloud mode | empty | Paddle Founder annual price ID. | | `OPENPOST_PADDLE_PRO_MONTHLY_PRICE_ID` | Required in cloud mode | empty | Paddle Pro monthly price ID. | | `OPENPOST_PADDLE_PRO_ANNUAL_PRICE_ID` | Required in cloud mode | empty | Paddle Pro annual price ID. | | `OPENPOST_PADDLE_TEAM_MONTHLY_PRICE_ID` | Required in cloud mode | empty | Paddle Team monthly price ID. | | `OPENPOST_PADDLE_TEAM_ANNUAL_PRICE_ID` | Required in cloud mode | empty | Paddle Team annual price ID. | | `OPENPOST_PADDLE_AGENCY_MONTHLY_PRICE_ID` | Required in cloud mode | empty | Paddle Agency monthly price ID. | | `OPENPOST_PADDLE_AGENCY_ANNUAL_PRICE_ID` | Required in cloud mode | empty | Paddle Agency annual price ID. | | `OPENPOST_X_MONTHLY_BUDGET_MICROUSD` | No | `5000000` | Cloud-only per-workspace X request safety limit in millionths of a US dollar. `0` blocks hosted X publishing. | | `OPENPOST_X_POST_CREATE_COST_MICROUSD` | No | `15000` | Estimated cloud X cost for a post without a URL, in millionths of a US dollar. Keep this aligned with current X pricing. | | `OPENPOST_X_POST_CREATE_WITH_URL_COST_MICROUSD` | No | `200000` | Estimated cloud X cost for a post with a URL, in millionths of a US dollar. Keep this aligned with current X pricing. | | `OPENPOST_X_ENGAGEMENT_DAILY_READ_BUDGET` | No | `12` | Instance/Hosted policy limit for provider-reaching X engagement read attempts per account and UTC day. Scheduled and manual refreshes share this durable budget; `0` disables X engagement reads. | | `OPENPOST_PROVIDER_USAGE_RETENTION_DAYS` | No | `180` | Retention for confirmed provider-cost events and unresolved reservations. Startup pruning is bounded and never removes the open month. | The official hosted policy URLs and versions come from `packages/legal-policy/src/manifest.json`. Run `bun scripts/legal-policy-manifest.mjs env` to print the four non-secret environment values. Cloud startup fails closed when the configured official URLs or versions drift from that manifest, so a policy change cannot silently record acceptance against old text. A substantive Terms or Privacy change advances its version and causes existing accounts to see the acceptance screen again. Spelling, formatting, and link-only corrections keep the existing version. The Refund Policy is incorporated into the Terms and does not have a separate acceptance record. #### Automatic image alt text Automatic alt text is off when `OPENROUTER_API_KEY` is empty. When it is configured, adding an image with no saved alt text to the text-and-thread composer sends a 400px JPEG thumbnail from the server to OpenRouter. When present, OpenPost also sends up to 1,000 characters of the current relevant post or thread segment as untrusted context to help the model distinguish what the image means in that post. The model is instructed to treat this text as context, not as instructions. OpenPost restricts routing to eligible providers that declare they do not collect request data. Operators can also pin one exact provider and require OpenRouter's zero-data-retention classification. The result is saved as the media item's shared base alt text only if that field is still blank. The original image is not sent for this task. The Hosted service pins `azure/eu`, disables provider fallback, and requires ZDR; cloud startup fails closed if that boundary drifts. This is external processing: the thumbnail and any relevant segment text leave the OpenPost instance and are handled by OpenRouter and the selected model provider. Review their current privacy and retention terms before enabling the feature. Existing or newly entered manual alt text always wins. With no key, OpenPost makes no caption request. A captioning failure does not stop users from attaching or publishing media. #### Meme maker The meme maker is on by default. OpenPost embeds a pinned template catalog, validates template IDs and caption counts, renders each result in the backend, and saves the chosen image in the workspace Media library. Captions and replaceable workspace images do not leave the OpenPost instance. OpenPost saves an immutable recipe with captions, overlay media IDs, output format, catalog revision, and a safe template source link when one is available. Manual browsing, caption editing, preview, and rendering need no outside service. AI suggestions also need `OPENROUTER_API_KEY`: OpenPost sends the idea plus a bounded shortlist of template names and written semantic notes to the configured model, validates its structured response, and leaves every caption editable before rendering. It does not send template images, workspace media, the full catalog, or the original idea in the saved recipe. The catalog comes from a pinned Memegen source snapshot. Its source links record provenance, not a license for every depicted work or character. Confirm that you can publish the template you choose. AI suggestions use the same OpenRouter privacy boundary as other configured AI features; keep the key empty if drafts must stay fully inside the instance. #### Update status When enabled in `selfhost` mode, the Instance settings page lets an instance admin compare the running version and build revision with the latest stable OpenPost release. The server checks the fixed public GitHub release endpoint only when an admin requests this page. It sends no hostname, account data, content, or credentials. Successful responses are cached for 24 hours. Failed checks retry after 15 minutes, use a three-second timeout, and keep the last successful result as stale. Responses are limited to 64 KiB and release links must point back to the official OpenPost GitHub repository. The feature never downloads or installs an update. See [Update Status](https://docs.openpo.st/configuration/update-status.md) for the full boundary. #### Custom connectors Self-hosted operators can load the custom connector registry from an absolute path with `OPENPOST_CONNECTORS_FILE`. The registry is read at startup. See [Custom Connectors](https://docs.openpo.st/configuration/custom-connectors.md) for the file schema and security boundary. #### Provider app registry OpenPost builds provider adapters at startup from active encrypted `provider_apps` database rows, legacy provider env vars, and optional `OPENPOST_PROVIDER_APPS` JSON. Environment-defined apps are authoritative over matching database rows. `OPENPOST_DISABLED_PROVIDERS` is an emergency comma-, space-, or newline-separated deny-list of provider keys. It has priority over database runtime-control events and fails closed across connection, capability and schedule decisions, and queued worker writes after restart. Use append-only runtime-control events for normal operator changes and the environment list when the database control plane must not be trusted. `OPENPOST_PROVIDER_CERTIFICATION_ENFORCED` is a cloud-only strict evidence gate and defaults to `false`. Enable it only after every enabled production subject has current runtime-control, approval, local-test, live-test, and OAuth-scope evidence. Production identity and public certification claims remain production-scoped when this flag is off; the flag only controls whether missing certification evidence blocks connection and publishing. Database rows are intended for administrator-managed installs. They store `client_secret_encrypted` with the same `OPENPOST_ENCRYPTION_KEY` used for account tokens and act as fallbacks when no matching environment app exists. They require a server restart after changes. Matching is by provider, except Mastodon uses provider plus `instance_url`. Instance admins can manage encrypted database rows through `GET /api/v1/admin/provider-apps`, `POST /api/v1/admin/provider-apps`, and `DELETE /api/v1/admin/provider-apps/{id}`. API responses never return client secrets; send `client_secret` only when creating a row or rotating the existing secret. The backend exposes this registry through the instance-admin API and **Settings → Instance → Configuration → Provider apps**. Environment-defined apps appear as read-only. A matching database fallback stays visible and can be deleted while the environment app remains active. Admin-added client secrets are encrypted and never returned. Saves and deletes take effect after the next OpenPost server restart. #### External analytics sources `OPENPOST_ANALYTICS_SOURCES` lets an operator override analytics collection per platform without changing publishing, OAuth, the analytics job cadence, unchanged-metric backoff, manual refresh, snapshots, sync state, or the per-account analytics feature gate. The configured source replaces only the platform's `AnalyticsAdapter` inside the analytics service. `OPENPOST_ANALYTICS_SOURCES` also supports `_FILE`, so `OPENPOST_ANALYTICS_SOURCES_FILE=/run/secrets/openpost-analytics-sources.json` works through the normal config loader. OpenPost validates this JSON at startup. Each entry needs a unique `platform`, an absolute `http` or `https` `base_url`, and a non-empty `bearer_token`. Query strings, fragments, and inline URL credentials are rejected. The client uses a fixed timeout, bounds response bodies to 64 KiB, rejects redirects, and never stores or exposes the source token or response body. | Variable | Required | Default | Description | | ---------------------------- | -------: | ------- | ---------------------------------------------------------------------------------------------------------- | | `OPENPOST_ANALYTICS_SOURCES` | No | empty | Structured JSON external analytics source registry. Each entry overrides analytics reads for one platform. | Example: ```json [ { "platform": "linkedin", "base_url": "https://collector.example/openpost", "bearer_token": "replace-me" } ] ``` For each configured platform, OpenPost calls: - `POST /analytics/account` with `{"platform":"linkedin","account_id":"urn:li:person:123"}` - `POST /analytics/content` with `{"platform":"linkedin","account_id":"urn:li:person:123","external_ids":["urn:li:share:456"],"published_at":"2026-08-28T12:00:00Z"}` Both requests send `Authorization: Bearer ` and `Content-Type: application/json`. Success responses return `metrics` as named non-negative integer counters: ```json { "metrics": { "followers": 1234 } } ``` ```json { "metrics": { "impressions": 5420, "reach": 3200, "likes": 156 } } ``` Failure responses return one explicit source status: ```json { "status": "unsupported", "code": "collector_not_enabled" } ``` ```json { "status": "permission_required", "code": "collector_auth" } ``` ```json { "status": "rate_limited", "code": "collector_busy", "retry_after_seconds": 3600 } ``` ```json { "status": "not_found", "code": "post_missing" } ``` ```json { "status": "failed", "code": "collector_error", "retry_after_seconds": 300 } ``` #### X | Variable | Required | Default | Description | | ------------------------------------------ | --------: | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `OPENPOST_PROVIDER_APPS` | No | empty | Structured JSON provider app registry. Entries override matching legacy env providers and encrypted database rows. | | `OPENPOST_PROVIDER_CERTIFICATION_ENFORCED` | No | `false` | In cloud mode, require complete certification evidence before connection or publishing. | | `X_CLIENT_ID` | Yes for X | empty | X OAuth client ID. Leave empty to disable X. | | `X_CLIENT_SECRET` | Yes for X | empty | X OAuth client secret. | | `X_REDIRECT_URI` | No | derived from `OPENPOST_APP_URL` | X OAuth callback URL override. | #### Mastodon | Variable | Required | Default | Description | | ----------------------- | -------: | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `MASTODON_REDIRECT_URI` | No | `urn:ietf:wg:oauth:2.0:oob` | Mastodon redirect URI. The default uses the OOB flow and does not need a public callback URL. | | `MASTODON_SERVERS` | No | `[]` | JSON array of operator-pinned Mastodon apps and instance URLs. Leave empty when relying on custom instance registration from the Accounts screen. | #### LinkedIn | Variable | Required | Default | Description | | ------------------------------------------ | ---------------: | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `LINKEDIN_CLIENT_ID` | Yes for LinkedIn | empty | LinkedIn OAuth client ID. Leave empty to disable LinkedIn. | | `LINKEDIN_CLIENT_SECRET` | Yes for LinkedIn | empty | LinkedIn OAuth client secret. | | `LINKEDIN_REDIRECT_URI` | No | derived from `OPENPOST_APP_URL` | LinkedIn callback URL override. | | `OPENPOST_DISABLE_LINKEDIN_THREAD_REPLIES` | No | `false` | Disable LinkedIn comment-style child replies for thread posts. Legacy `LINKEDIN_DISABLE_THREAD_REPLIES` remains supported. | | `OPENPOST_LINKEDIN_ORGANIZATIONS_ENABLED` | No | `false` | Request approved LinkedIn organization permissions and offer administered Pages during connection. | | `LINKEDIN_API_VERSION` | No | previous calendar month | Override the LinkedIn REST API version. Read directly by the adapter; `_FILE` is not supported. | #### Threads | Variable | Required | Default | Description | | ------------------------ | --------------: | ------------------------------- | --------------------------------------------------------------------------- | | `THREADS_CLIENT_ID` | Yes for Threads | empty | Meta app ID. Leave empty to disable Threads. | | `THREADS_CLIENT_SECRET` | Yes for Threads | empty | Meta app secret. | | `THREADS_REDIRECT_URI` | No | derived from `OPENPOST_APP_URL` | Threads callback URL override. Threads production redirects must use HTTPS. | | `META_GRAPH_API_VERSION` | No | `v25.0` | Meta Graph API version used by the Facebook Pages and Instagram adapters. | #### Facebook Facebook Pages publishing is configured through the provider app registry instead of legacy provider-specific env vars. Use `OPENPOST_PROVIDER_APPS` for bootstrap/self-hosting or the instance-admin provider app API for hosted/operator-managed credentials. Example: ```json [ { "provider": "facebook", "client_id": "your-meta-app-id", "client_secret": "your-meta-app-secret" } ] ``` If `redirect_uri` is omitted, OpenPost derives `https://your-domain.com/api/v1/accounts/facebook/callback` from `OPENPOST_APP_URL`. Facebook media publishing requires `OPENPOST_MEDIA_URL` or `OPENPOST_S3_PUBLIC_BASE_URL` to point at public HTTPS media URLs. #### Instagram Instagram professional publishing is configured through the provider app registry instead of legacy provider-specific env vars. Use `OPENPOST_PROVIDER_APPS` for bootstrap/self-hosting or the instance-admin provider app API for hosted/operator-managed credentials. Example: ```json [ { "provider": "instagram", "client_id": "your-meta-app-id", "client_secret": "your-meta-app-secret" } ] ``` If `redirect_uri` is omitted, OpenPost derives `https://your-domain.com/api/v1/accounts/instagram/callback` from `OPENPOST_APP_URL`. Instagram media publishing requires `OPENPOST_MEDIA_URL` or `OPENPOST_S3_PUBLIC_BASE_URL` to point at public HTTPS media URLs. #### TikTok TikTok is configured through the provider app registry instead of legacy provider-specific env vars. Use `OPENPOST_PROVIDER_APPS` for bootstrap/self-hosting or the instance-admin provider app API for hosted/operator-managed credentials. Example: ```json [ { "provider": "tiktok", "client_id": "your-client-key", "client_secret": "your-client-secret", "redirect_uri": "https://your-domain.com/api/v1/accounts/tiktok/callback" } ] ``` TikTok direct video publishing requires `OPENPOST_MEDIA_URL` or `OPENPOST_S3_PUBLIC_BASE_URL` to point at public HTTPS media URLs. #### YouTube YouTube video uploads are configured through the provider app registry instead of legacy provider-specific env vars. Use `OPENPOST_PROVIDER_APPS` for bootstrap/self-hosting or the instance-admin provider app API for hosted/operator-managed credentials. Example: ```json [ { "provider": "youtube", "client_id": "your-google-oauth-client-id", "client_secret": "your-google-oauth-client-secret" } ] ``` If `redirect_uri` is omitted, OpenPost derives `https://your-domain.com/api/v1/accounts/youtube/callback` from `OPENPOST_APP_URL`. OpenPost uploads one video per YouTube rendition, uses private visibility by default, accepts explicit YouTube privacy and metadata settings, and derives fallback title and description text from the post or platform variant. #### Notes - The preferred names above are what new deployments should use. - `OPENPOST_PROVIDER_APPS` accepts provider metadata plus instance-owned secrets. OAuth apps use `client_id`, `client_secret`, and `redirect_uri`; Telegram uses `bot_token`, `bot_username`, and `webhook_secret`; Discord bot apps use `connection_mode: "bot"`, `client_id` (the application ID), `client_secret`, `bot_token`, and `redirect_uri`. Database-backed secrets are encrypted. Supported configured identities include `x`, `mastodon`, `linkedin`, `threads`, `facebook`, `instagram`, `tiktok`, `youtube`, `pinterest`, `telegram`, and Discord bot mode. Bluesky credentials and Discord incoming webhooks remain user-owned connection modes. - Backward-compatible aliases still work for existing installs: `DATABASE_URL`, `OPENPOST_DB_PATH`, `OPENPOST_FRONTEND_URL`, `OPENPOST_CORS_EXTRA_ORIGINS`, `JWT_SECRET`, `ENCRYPTION_KEY`, `TWITTER_CLIENT_ID`, `TWITTER_CLIENT_SECRET`, `TWITTER_REDIRECT_URI`, and `LINKEDIN_DISABLE_THREAD_REPLIES`. - File-backed aliases also work for existing installs, such as `DATABASE_URL_FILE`, `JWT_SECRET_FILE`, and `ENCRYPTION_KEY_FILE`. - The root `.env.example` is the best copy-paste starting point. - Set explicit public URLs in production even when defaults exist. - For Threads, Facebook, Instagram, and TikTok, treat `OPENPOST_MEDIA_URL` as mandatory unless S3/R2 public media URLs are configured. ### User Feedback Source: [https://docs.openpo.st/configuration/feedback.md](https://docs.openpo.st/configuration/feedback.md) This page is for operators configuring feedback delivery, diagnostics, and privacy limits. OpenPost can show authenticated users a report form for bugs, ideas, and questions. It is disabled by default and has no hardcoded destination. #### Configure a destination Set all three values and restart OpenPost: ```sh OPENPOST_FEEDBACK_ENABLED=true OPENPOST_FEEDBACK_DESTINATION_URL_FILE=/run/secrets/openpost-feedback-webhook OPENPOST_FEEDBACK_RECIPIENT="Example operator" ``` `OPENPOST_FEEDBACK_DESTINATION_URL` must be an HTTPS Discord-compatible webhook. The first implementation supports that destination format only. The URL stays on the server. `OPENPOST_FEEDBACK_RECIPIENT` is the exact name users see before sending. Hosted OpenPost uses the same configuration. A self-hosted instance does not send reports to the OpenPost maintainers unless its operator explicitly configures such a destination and names that recipient. Set `OPENPOST_FEEDBACK_SUPPORT_URL` to an HTTPS issue or support page. When delivery is disabled, the app shows this link instead of a dead form. Its query string and fragment are removed before display. #### What the user controls The message is required. Screenshot and diagnostics are off by default and independent: - A screenshot is captured only after the user enables it. The user sees it and can remove it. - Diagnostics show their categories and exact JSON before send. - Closing the form sends nothing. A saved response means OpenPost saved the report and will send it in the background. It does not mean the receiving service has it yet. OpenPost retries a failed webhook up to the job limit. #### Privacy limits Diagnostics may contain: - OpenPost version and current route path; - the Svelte route template when known; - viewport and browser family/version; - up to 10 recent route paths; - up to 15 failed OpenPost API requests with method, path, status, duration, and time; - up to 10 client errors reduced to coarse messages. Diagnostics never include cookies, headers, tokens, OAuth codes, request or response bodies, post text, uploaded files, arbitrary page state, full browser user-agent strings, local paths, private hosts, or query values. The browser sanitizes the report first and the server repeats validation before queueing and again before delivery. Screenshots omit the feedback dialog, form controls, cross-origin images, and elements marked with `data-feedback-redact` or `data-feedback-ignore`. They use a pixel ratio of 1, are capped at 1600 by 1200 in the browser, and must pass the server's MIME, dimension, pixel, and 1 MiB encoded-image limits. A report body is capped at 2 MiB. Screenshot failure leaves the text report usable. Normal logs contain job and destination failure status, not report diagnostics or provider response bodies. #### Current decisions - Feedback is available only to authenticated users and is limited to five submissions per user per minute. The fixed window is stored in the database, so a restart or a second hosted app instance does not reset the limit. - The destination is server-configured; there is no browser-to-webhook path and no maintainer endpoint. - Queued delivery and failed attempts remain visible through the existing jobs administration surface. Normal logs do not contain the report body or diagnostics. - A post retry uses the same saved job. You can retry one failed account or all failed accounts without posting again to accounts that worked. ### Configuration Source: [https://docs.openpo.st/configuration/index.md](https://docs.openpo.st/configuration/index.md) Use this page when configuring a self-hosted deployment or the Hosted service. Use the linked references for complete environment-variable and provider-application details. OpenPost settings fall into these groups: - Server: port, public frontend URL, extra CORS origins - Database: SQLite path by default, or Postgres URL for cloud deployments - Secrets: JWT signing and token encryption - Media: local filesystem path by default, or S3-compatible storage for cloud deployments - Social networks: [provider application](https://docs.openpo.st/configuration/provider-applications.md) ownership, client keys, callback addresses, and server-specific settings - Custom destinations: [connector](https://docs.openpo.st/configuration/custom-connectors.md) services installed by a self-hosted operator - Operations: self-hosted update checks and cloud-only limits on social network costs - Platform-specific behavior: options such as LinkedIn thread reply disabling For the full list, start with [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md). ### Invitation delivery callbacks Source: [https://docs.openpo.st/configuration/invitation-delivery-callbacks.md](https://docs.openpo.st/configuration/invitation-delivery-callbacks.md) This page is for operators integrating an email provider's delivery callbacks. OpenPost separates email provider acceptance from confirmed delivery. A successful delivery job marks a Workspace invitation **Sent**. It means the provider accepted the request. It does not prove delivery. A verified callback can later mark that exact delivery **Delivered** or **Delivery failed**. Set `OPENPOST_EMAIL_DELIVERY_WEBHOOK_SECRET` and send callbacks to `POST /api/v1/email/delivery/webhook`. Sign the exact request body with HMAC-SHA256 and send the lowercase hexadecimal digest as `OpenPost-Signature: v1=`. ```json { "event_id": "provider-event-unique-id", "invitation_id": "OpenPost invitation ID", "delivery_id": "OpenPost email delivery job ID", "outcome": "delivered", "occurred_at": "2026-08-14T12:01:00Z" } ``` `outcome` is `delivered` or `failed`. `event_id` must be stable across provider retries. OpenPost records only these identifiers, the outcome, and timestamps. It does not store the callback body, recipient address, provider response, or invitation secret in callback evidence. Duplicate event IDs return success without applying the event again. OpenPost ignores callbacks for an older resend generation, an accepted or revoked invitation, or an outcome older than the current callback state. Unknown invitation IDs also return a successful ignored result so the endpoint does not become an invitation lookup surface. ### Media Storage Source: [https://docs.openpo.st/configuration/media-storage.md](https://docs.openpo.st/configuration/media-storage.md) This page is for operators choosing local or S3-compatible media storage. OpenPost stores media on the local file system by default. It can also use S3 or R2 storage. #### Video processing dependency OpenPost requires `ffmpeg` and `ffprobe` from the same FFmpeg installation on `PATH` to verify uploaded videos and create posters. The official Docker image includes both tools. Project development and standalone release checks use the `pkgs.ffmpeg` input pinned by `devenv.lock`. Standalone binaries do not bundle FFmpeg, so install a maintained FFmpeg package for the host before starting OpenPost and keep both commands together when upgrading it. A video stays unavailable for publishing if the server cannot complete this verification; Media shows the processing error and offers a retry after the dependency or file problem is fixed. #### Key settings - `OPENPOST_MEDIA_PATH` controls where files are stored on disk. - `OPENPOST_MEDIA_URL` controls how those files are exposed publicly. Its default `/media` path is resolved against `OPENPOST_PUBLIC_URL`, which falls back to `OPENPOST_APP_URL`. - `OPENPOST_STORAGE_DRIVER` chooses `local` or `s3`. #### Recommended production values ```sh OPENPOST_MEDIA_PATH=/data/media OPENPOST_APP_URL=https://openpost.example.com OPENPOST_PUBLIC_URL=https://openpost.example.com ``` The default `OPENPOST_MEDIA_URL=/media` becomes `https://openpost.example.com/media`. Set an absolute `OPENPOST_MEDIA_URL` when media uses a separate domain or path. #### Why public media URLs matter Threads, Facebook, Instagram, and some TikTok posts need a public HTTPS link to the media. Those posts will fail if the social network cannot open the file. #### Backups Back up the media directory together with the SQLite database when using local storage. For S3/R2-style storage, back up the bucket or configure provider-side versioning and lifecycle protection. #### Lifecycle cleanup OpenPost applies a fixed application-level lifecycle to local and S3-compatible storage: - Post-specific temporary media moves to Trash after its final successful publication or 14 days without use. - Trash remains recoverable for seven days, then becomes eligible for permanent removal. - Favorites, tags, collections, brand files, active posts and publications, retryable work, source relationships, and live editor projects protect their media from automatic cleanup. These periods are not workspace or environment settings. The deprecated `media_cleanup_days` API field remains only for old clients: reads return `14` and writes are ignored. Old queued cleanup jobs that contain a `days` value also use the fixed 14-day policy. Each cleanup run computes protection once for a bounded database batch. OpenPost commits database changes before it asks local or remote object storage to delete bytes, so a slow storage service does not keep a SQLite or PostgreSQL transaction open. #### S3-compatible storage Use these settings for S3/R2-style storage: ```sh OPENPOST_STORAGE_DRIVER=s3 OPENPOST_S3_ENDPOINT=https://.r2.cloudflarestorage.com OPENPOST_S3_REGION=auto OPENPOST_S3_BUCKET=openpost-media OPENPOST_S3_ACCESS_KEY_ID=... OPENPOST_S3_SECRET_ACCESS_KEY=... OPENPOST_S3_PUBLIC_BASE_URL=https://media.openpost.example OPENPOST_S3_FORCE_PATH_STYLE=false ``` #### Cloud mode When `OPENPOST_EDITION=cloud`, OpenPost refuses to start unless: - `OPENPOST_STORAGE_DRIVER=s3` - `OPENPOST_S3_REGION` is set - `OPENPOST_S3_BUCKET` is set - `OPENPOST_S3_ACCESS_KEY_ID` is set - `OPENPOST_S3_SECRET_ACCESS_KEY` is set - `OPENPOST_S3_PUBLIC_BASE_URL` is set `OPENPOST_S3_PUBLIC_BASE_URL` is required in cloud mode because social networks need stable public media links. At startup, OpenPost writes, reads, verifies, and deletes a small object under `.openpost-readiness/`. The bucket credential must allow `PutObject`, `GetObject`, and `DeleteObject`, not only bucket discovery. The readiness endpoint repeats this bounded capability check after a short cache interval and returns `503` if required object storage is unavailable. Normal S3 reads, writes, multipart uploads, and deletes inherit caller cancellation and deadlines without imposing a shorter limit on large media streams. The browser can upload straight to S3 or R2. OpenPost sends larger files in parts without loading the whole file into memory. For direct browser uploads, the bucket must allow CORS requests from the OpenPost app origin. For Cloudflare R2, apply a bucket CORS rule like this, replacing the origin with your `OPENPOST_APP_URL`: ```json { "rules": [ { "allowed": { "origins": ["https://app.openpost.example"], "methods": ["PUT"], "headers": ["Content-Type"] }, "exposeHeaders": ["ETag"], "maxAgeSeconds": 3600 } ] } ``` Save the rule as `cors.json`, apply it with `wrangler r2 bucket cors set --file cors.json`, and verify it with `wrangler r2 bucket cors list `. Without this rule, the browser blocks the upload and reports `Failed to fetch`. Streaming upload flow: 1. Call `POST /api/v1/media/upload-session` with `workspace_id`, `filename`, `mime_type`, and `size`. 2. Upload the file to the returned `PUT` target with the returned headers. For a relative OpenPost target, use the same OpenPost bearer credential or browser session. For an absolute presigned bucket target, send only the returned upload headers; never send the OpenPost bearer token or session cookie to the storage host. 3. Call `POST /api/v1/media/upload-session/{media_id}/complete` with the same `workspace_id`. S3-compatible storage returns a presigned browser-to-bucket target for files within the provider's single-request limit. Larger files use an authenticated OpenPost target and are written to the bucket as 8 MiB multipart parts. Local storage uses the same authenticated streaming target and writes directly to disk. Configure the reverse proxy in front of OpenPost to accept the largest video size you intend to support; X subscribed accounts can upload videos as large as 16 GiB. OpenPost makes a pending Media item first. After upload, it checks the saved file, finds matching files with SHA-256, makes a thumbnail when possible, records usage, and marks the file ready. It does not keep a large file in app memory. The web app uses upload sessions automatically for current local and S3-compatible deployments. It falls back to the legacy multipart endpoint only when the server does not advertise upload-session support. OpenPost can reuse a file it already sent to a social network when one account needs a retry. Networks that fetch a public media link, such as Threads, Instagram, Facebook, and TikTok, get a fresh link instead. ### Production Checklist Source: [https://docs.openpo.st/configuration/production-checklist.md](https://docs.openpo.st/configuration/production-checklist.md) Use this page before putting a real OpenPost instance behind a public domain. It is operator-facing: product usage lives in [User Docs](https://docs.openpo.st/usage/index.md), while code changes live in [Developer Docs](https://docs.openpo.st/development/index.md). #### Baseline - [ ] Copy the root `.env.example` to `.env` or mirror every required value in your secret manager. - [ ] Generate fresh `OPENPOST_JWT_SECRET` and `OPENPOST_ENCRYPTION_KEY`. - [ ] Keep both secrets at least 32 characters long. - [ ] Store secrets outside the repository and outside container images. - [ ] Use `_FILE` variants for Docker/Podman/Kubernetes/NixOS secrets, and leave the direct variable unset when the file value should win. - [ ] Set `OPENPOST_APP_URL` to the public HTTPS app origin. - [ ] Set `OPENPOST_PUBLIC_URL` to the same public HTTPS app origin unless you have a specific split-origin reason. - [ ] Configure `OPENPOST_EMAIL_PROVIDER`, `OPENPOST_EMAIL_FROM`, and that provider's credentials; verify signup, password-reset, and one opted-in operational notification without logging codes or secrets. - [ ] If the mail provider can report delivery, set `OPENPOST_EMAIL_DELIVERY_WEBHOOK_SECRET`, configure `POST /api/v1/email/delivery/webhook`, and verify **Sent** changes to **Delivered** only after a signed callback. - [ ] If Google login is enabled, register the exact `/api/v1/auth/oidc/google/callback` URL and store `OPENPOST_AUTH_GOOGLE_CLIENT_SECRET` through a file-backed secret. - [ ] Keep `OPENPOST_EXTRA_CORS_ORIGINS` explicit and do not use `*`. - [ ] Configure a reverse proxy with HTTPS before connecting OAuth providers. - [ ] Align reverse-proxy and CDN request-body limits with the largest video you accept, and disable request buffering for streamed uploads. - [ ] Decide whether to enable automatic image alt text. If enabled, store `OPENROUTER_API_KEY` through `OPENROUTER_API_KEY_FILE` and review OpenRouter and model provider privacy and retention terms. - [ ] Review the built-in meme catalog's source links and template-rights boundary. If AI meme suggestions are enabled, review the configured OpenRouter model and privacy terms; manual rendering stays inside OpenPost. - [ ] Confirm `GET /api/v1/health` returns `{"status":"ok"}`. - [ ] Confirm `GET /api/v1/ready` returns `{"status":"ready","database":"ok"}`. - [ ] Use `/api/v1/health` for process liveness and `/api/v1/ready` for traffic admission, rollouts, and dependency-aware monitoring. - [ ] Confirm `openpost instance health --instance ` succeeds against the public URL. - [ ] Capture `openpost instance diagnostics --instance --json` for the launch/support handoff. - [ ] Decide whether to enable PostHog telemetry. Self-hosted instances keep it disabled unless the operator explicitly chooses an operator-owned project and updates their privacy notice. #### Self-Hosted Storage - [ ] Keep `OPENPOST_EDITION=selfhost` or leave it unset. - [ ] Use SQLite/local storage unless you intentionally operate Postgres/S3 yourself. - [ ] Persist the SQLite database path, usually `/data/db/openpost.db`. - [ ] Persist the local media directory, usually `/data/media`. - [ ] Set `OPENPOST_MEDIA_URL` to the public media base URL. - [ ] Back up database files, media files, and secrets together. - [ ] Run at least one test restore before relying on the backup. #### Hosted Service Operators - [ ] Set `OPENPOST_EDITION=cloud`. - [ ] Set `OPENPOST_DATABASE_DRIVER=postgres`. - [ ] Set `OPENPOST_DATABASE_URL` to the production Postgres URL. - [ ] Set `OPENPOST_STORAGE_DRIVER=s3`. - [ ] Set `OPENPOST_S3_REGION`, `OPENPOST_S3_BUCKET`, `OPENPOST_S3_ACCESS_KEY_ID`, and `OPENPOST_S3_SECRET_ACCESS_KEY`. - [ ] Set `OPENPOST_S3_PUBLIC_BASE_URL` to a stable public media origin. - [ ] Verify the S3 bucket lifecycle policy and object access model before launch. - [ ] Set `OPENPOST_PADDLE_API_KEY`, `OPENPOST_PADDLE_ENVIRONMENT=production`, `OPENPOST_PADDLE_CLIENT_TOKEN`, `OPENPOST_PADDLE_WEBHOOK_SECRET`, and `OPENPOST_PADDLE_CHECKOUT_RETURN_URL`. - [ ] Set the monthly and annual `OPENPOST_PADDLE___PRICE_ID` values for Starter, Founder, Pro, Team, and Agency. - [ ] Confirm the production API key and client token have live prefixes; never deploy sandbox credentials to the Hosted service. - [ ] Configure Paddle to send customer, subscription, and `transaction.completed` events to `/api/v1/billing/paddle/webhook`; send a signed test event and confirm it is stored once and reconciled through the billing job. - [ ] Set the minimum and maximum quantity to `1` for every Paddle plan price so buyers cannot add duplicate copies of a workspace subscription. - [ ] Before upgrading from Whop billing, migrate every active customer to Paddle and confirm the Paddle subscription is reconciled locally. Historical Whop rows do not grant entitlements after the upgrade. - [ ] Complete an embedded checkout smoke: plan and period selection, $0 trial start, return to OpenPost, local `trialing` status, and billing management URL. - [ ] Confirm a new hosted user can create the bootstrap workspace and is blocked from extra workspaces before checkout. - [ ] Confirm team invitations are blocked once active members plus pending invites reach the plan limit. - [ ] Configure one EU PostHog production project for the app, backend, marketing site, and documentation; use separate staging and development projects. - [ ] Enable stateful cookieless server hashing; keep raw-IP discard on after approved persistent-event country and bot enrichment; remove city, region, coordinates, postal code, and accuracy-radius properties; set event retention to no more than 12 months. - [ ] Keep broad autocapture, session replay, heatmaps, surveys, dead clicks, console capture, network timing and bodies, and automatic browser exception capture disabled; enable only CLS, FCP, INP, and LCP Web Vitals. - [ ] Set the PostHog project token and server, browser, and UI hosts; keep the personal API key only in CI source-map upload secrets. - [ ] Verify `GET /api/v1/telemetry/config` exposes only the browser-safe project token and ingestion configuration. - [ ] Verify undecided and fully off browsers send no optional events; persistent events keep cross-subdomain identity and country without raw IP; cookieless events stay personless with no geography or browser correlation headers. - [ ] Verify a browser intent event, a server outcome event, and one sanitized test exception arrive without content, credentials, email, names, query strings, raw URLs, or precise location properties. #### Providers - [ ] Start with Bluesky or Mastodon for the first end-to-end publish smoke. - [ ] Update callback URLs for X, LinkedIn, Threads, Facebook, Instagram, TikTok, and YouTube to the production HTTPS app origin. - [ ] Add Facebook through `OPENPOST_PROVIDER_APPS` or the instance-admin provider app API if Facebook Pages publishing is enabled, and confirm `OPENPOST_MEDIA_URL` serves public HTTPS media for media posts. - [ ] Add Instagram through `OPENPOST_PROVIDER_APPS` or the instance-admin provider app API if Instagram professional publishing is enabled, and confirm `OPENPOST_MEDIA_URL` serves public HTTPS media. - [ ] Add TikTok through `OPENPOST_PROVIDER_APPS` or the instance-admin provider app API if short-form video publishing is enabled, and confirm `OPENPOST_MEDIA_URL` serves public HTTPS media. - [ ] Add YouTube through `OPENPOST_PROVIDER_APPS` or the instance-admin provider app API if video uploads are enabled, and confirm the Google Cloud project has YouTube Data API v3 enabled. - [ ] Configure Mastodon servers in `MASTODON_SERVERS` if you need fixed self-hosted Mastodon apps. - [ ] Confirm custom Mastodon instance registration works if you rely on dynamic Mastodon connections. - [ ] Limit each new social network until OAuth, media, publishing, token refresh, retries, and API limits pass a live account test. - [ ] Create one test account connection per enabled provider. - [ ] Publish a private or low-risk test post with and without media for every enabled provider. #### Product Smoke - [ ] Create the first admin account. - [ ] Confirm email-and-password signup cannot create a session until the six-digit code is accepted, and confirm resend invalidates the prior code. - [ ] Confirm Google can create a new account, then link and unlink Google from an existing password account without email-based auto-linking. - [ ] Decide whether to set `OPENPOST_DISABLE_REGISTRATIONS=true`. - [ ] Create a workspace. - [ ] Invite a second user, accept the link, and confirm both members appear in **Settings → Workspace → Members**. - [ ] Connect at least one social account. - [ ] Upload a small image and confirm it appears in the media library. - [ ] If automatic alt text is enabled, add an image without alt text to the text-and-thread composer, confirm OpenPost fills its shared alt text, and confirm a manual edit is not replaced. - [ ] Create a draft and scheduled post from the web app. - [ ] Create a draft or scheduled post through the CLI. - [ ] Create a draft or scheduled post through MCP if assistant access is enabled. - [ ] Confirm scheduled publishing creates and completes a background job. #### Operations - [ ] Point dependency-aware uptime monitoring at `/api/v1/ready`; keep container or orchestrator liveness on `/api/v1/health`. - [ ] Confirm logs include startup configuration, database readiness errors, provider publish failures, and MCP tool-call failures. - [ ] Document your deployment rollback path. - [ ] Document where database backups, media backups, and secret backups live. - [ ] Verify the release artifact or container image matches the version you intended to deploy. ### Provider Applications Source: [https://docs.openpo.st/configuration/provider-applications.md](https://docs.openpo.st/configuration/provider-applications.md) This page is for operators configuring provider OAuth applications. A provider application identifies the OpenPost installation to a social network during OAuth. It is instance configuration, not a user's connected social-account token and not an OpenPost API or CLI token. OpenPost manages encrypted provider-application rows for `x`, `mastodon`, `linkedin`, `threads`, `facebook`, `instagram`, `tiktok`, `youtube`, `pinterest`, `telegram`, and Discord bot mode. Bluesky app passwords and Discord incoming webhooks remain user-owned connection modes, so the administrator API rejects those credential shapes. A configured provider application is not a public availability claim. Pinterest, Telegram bot mode, and Discord bot mode remain hidden or blocked for public Hosted use until their exact approval, scope, policy, runtime-control, and current live-certification evidence passes. Existing Discord incoming-webhook connections use a separate mode and remain supported. #### Ownership Provider console access, app review, callback registration, and the client credentials belong to the operator of the OpenPost instance. | Deployment | Responsible party | Recommended credential source | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | OpenPost Hosted service | The service operator owns the production provider projects, approvals, callbacks, and deployment secrets. Workspace users and workspace administrators do not provide the shared provider application. | Deployment secret storage exposed through legacy provider variables or `OPENPOST_PROVIDER_APPS_FILE`. | | Self-hosted instance | The self-hosted operator owns each provider project and grants instance-admin access only to people trusted to manage instance secrets. | Either deployment secret storage or encrypted database rows managed in the app or API. | OpenPost does not use the edition setting to remove the encrypted database API. An instance administrator on either edition can manage database fallback rows. On the Hosted service, an operator-owned environment entry remains authoritative, so an instance administrator cannot replace it through the database API. #### Where to configure an app An instance administrator can open **Settings → Instance → Configuration → Provider apps** for the existing OAuth form. Pinterest, Telegram, and Discord bot contract fields can be configured through `OPENPOST_PROVIDER_APPS` or the administrator API. Database-backed operations are available at: | Method | Route | Result | | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `GET` | `/api/v1/admin/provider-apps` | Lists non-secret metadata for database rows and effective environment entries. | | `POST` | `/api/v1/admin/provider-apps` | Creates or updates one encrypted database row. Omitting a secret field on update preserves its stored value. | | `DELETE` | `/api/v1/admin/provider-apps/{id}` | Deletes a database row. It cannot delete an environment entry. | All three routes require a signed-in browser session for an unscoped instance administrator. API, CLI, and MCP bearer tokens are rejected, including tokens owned by an instance administrator. A workspace role, including workspace administrator, does not grant instance administration. The Settings navigation hides the page from other users, but the backend authorization check is the security boundary. The database identity is the provider key for every listed provider except Mastodon. Mastodon uses the provider key plus its normalized `instance_url`, which permits one configured app per server. The API rejects `instance_url` for other providers. #### Source precedence OpenPost builds the effective adapter registry during server startup. For the same provider identity, the order from highest to lowest precedence is: 1. Operator-owned provider configuration from legacy variables or `OPENPOST_PROVIDER_APPS` and its `_FILE` form. 2. An active encrypted `provider_apps` database row. 3. A dynamically registered Mastodon application for the same server. Environment-defined apps appear in Settings with non-secret metadata, but they are read-only and cannot be deleted through the API. If a database fallback exists for the same identity, it remains visible as a stored fallback. It cannot be edited while shadowed, but it can be deleted. To change or disable an environment-defined app, update the deployment configuration and restart OpenPost. Bluesky and Discord webhook adapters are built in and do not use this precedence chain. A Discord bot application is a separate instance-owned configuration. Mastodon applications created automatically from the Accounts flow are stored separately from administrator-managed `provider_apps` rows. #### Secret handling - Database-backed client secrets, bot tokens, and webhook verification secrets are encrypted with AES-256-GCM using the key derived from `OPENPOST_ENCRYPTION_KEY`. The table stores only encrypted secret columns. - List and save responses return presence booleans such as `secret_configured`, `bot_token_configured`, and `webhook_secret_configured`, never stored secret values. They return only non-secret metadata such as client/application ID, callback URL, bot username, provider name, and Mastodon instance URL. - API clients must omit a secret field to preserve its existing ciphertext. Supplying a new value replaces OpenPost's stored copy; rotate the provider-side credential as part of the same procedure. - Environment and file-backed secrets remain in deployment configuration. OpenPost does not copy them into `provider_apps`. - Back up `OPENPOST_ENCRYPTION_KEY` separately from the database and protect both. OpenPost cannot decrypt database-backed provider apps after the key is lost or replaced. Do not put `OPENPOST_PROVIDER_APPS` JSON with client secrets in a Compose file, shell history, image, or repository. Prefer `OPENPOST_PROVIDER_APPS_FILE` or the secret-file mechanism of the deployment platform. A client ID is public application metadata; a client secret is sensitive. #### Applying and removing changes The provider adapter registry is not rebuilt during a request. A save or delete response therefore reports `requires_restart: true`, and the Settings page shows the same restart requirement. Restart through the deployment's normal controlled process, then verify the provider's callback and readiness before allowing production connections. Deleting or deactivating a database row does not revoke credentials at the social network and does not erase existing connected-account tokens. After restart, removing the effective app can prevent new OAuth connections and provider operations that require its adapter. Revoke provider-side access in the provider console when that is the intended outcome. If an active database row cannot be decrypted, startup fails instead of silently loading a broken provider adapter. Restore the matching encryption key before retrying the start. If that key is unavailable, recover a database-and-key backup that was captured as one matched set. See [Environment Variables](https://docs.openpo.st/configuration/environment-variables.md) for the JSON schema and legacy variables, [Callback URLs](https://docs.openpo.st/reference/callback-urls.md) for exact redirect paths, and [Provider Readiness and Launch Gate](https://docs.openpo.st/operations/provider-launch-matrix.md) for the separate approval and live-verification requirements. ### Product telemetry and error tracking Source: [https://docs.openpo.st/configuration/telemetry.md](https://docs.openpo.st/configuration/telemetry.md) OpenPost can send privacy-limited product, service, website, documentation, and error events to PostHog. The Hosted service enables this integration. Self-hosted installations leave it disabled unless their operator explicitly configures an operator-owned PostHog project. OpenPost uses both PostHog SDKs because they observe different facts: - `posthog-js` records browser page views, explicit product interactions, and sanitized browser failures. - `posthog-go` records authoritative server outcomes, background-job failures, and sanitized backend exceptions. Do not record the same outcome in both layers. Browser events describe intent, such as `publication publish requested`; server events describe confirmed outcomes, such as `rendition published`. The production first-use funnel uses these ordered events: 1. `signup started` — browser submission intent 2. `signup completed` — server account creation 3. `plan confirmed` — server purchase confirmation 4. `workspace created` — server first Workspace creation 5. `checkout completed` — server confirmation of the consumed successful checkout return 6. `destination connected` — server claim of the first connected destination 7. `first composition started` — browser confirmation after the server accepts the first meaningful composition claim 8. `workspace activated` — server exactly-once Activation transition The browser must not duplicate authoritative server outcomes. Funnel events accept only the properties declared in the shared browser and backend telemetry catalogues. Unknown event names, unknown properties, email addresses, secret-bearing URLs, and credential-shaped values are rejected before enqueue. #### Privacy boundary The shared browser client has four states: - **Undecided:** no optional browser telemetry is sent. - **Persistent:** after the visitor allows analytics cookies, PostHog keeps first-party identity and sessions across OpenPost sibling subdomains. The signed-in app identifies the browser only with the opaque OpenPost user ID. PostHog derives country and bot signals before discarding raw client IP. - **Cookieless:** after the visitor continues without analytics cookies, PostHog receives limited personless events with daily server-hashed identity, memory-only state, no geography, and no reliable multi-day retention. - **Off:** no optional browser telemetry or browser correlation headers are sent. Do Not Track and Global Privacy Control force this state. The preference is separate from mandatory policy acknowledgement and can be changed from **Analytics choices** in each browser surface. The Hosted service shares that choice through a first-party `openpost_analytics` cookie scoped to `.openpo.st`. Self-hosted deployments use a host-only cookie. Changing from an initialized mode clears the current origin's PostHog state and reloads; another sibling origin clears its own local state when it next loads. Autocapture, session replay, surveys, console capture, network timing and bodies, heatmaps, dead-click capture, and automatic exception capture are disabled. OpenPost records manual page views and matching page leaves. It records CLS, FCP, INP, and LCP without element attribution. Persistent marketing and documentation events remain anonymous until a user signs into the app. Cookieless events always remain personless. Backend product and service telemetry is a separate observation boundary. It records authoritative outcomes with opaque identifiers and keeps Go SDK GeoIP disabled. Browser analytics choices do not suppress these backend events, and cookieless browser requests do not send correlation headers that could join them to service outcomes. Do not add email addresses, names, usernames, post text, media, request bodies, credentials, query strings, or raw URLs to events or exceptions. Use route templates, stable object IDs, status codes, release identity, and enumerated failure types. A PostHog project token is write-only and may be exposed to the browser; a personal API key must remain in CI secret storage. `first composition started` is emitted only after the server atomically accepts the first meaningful composition for a Workspace. Its browser property allowlist contains only `signal`, with one of `text`, `media`, or `content_mode`. It excludes authored content, prompts, captions, media URLs, identity data, provider handles, destination identifiers, and secret-bearing URLs. Opening or focusing the composer, selecting a destination, and saving an empty draft do not emit it. Before enabling telemetry, configure the PostHog project to: - use stateful cookieless server hashing for the cookieless browser mode; - derive approved country and bot properties for persistent browser events, then discard raw client IP; - remove city, region, latitude, longitude, postal code, and accuracy-radius properties after enrichment; - retain events for no more than 12 months; - keep broad autocapture, automatic browser exception capture, replay, heatmaps, surveys, dead clicks, console capture, and network payload capture disabled; - enable Web Vitals for CLS, FCP, INP, and LCP while leaving network timing and element attribution disabled; - use the EU Cloud project when operating the official Hosted service. Changing that boundary requires a privacy review and a matching policy update before deployment. #### App and backend configuration Set these values in the process environment or in **Settings → Instance → Configuration**. Configuration changes take effect after restart. ```dotenv OPENPOST_TELEMETRY_ENABLED=true OPENPOST_POSTHOG_PROJECT_TOKEN=phc_operator_owned_project_token OPENPOST_POSTHOG_API_HOST=https://eu.i.posthog.com OPENPOST_POSTHOG_BROWSER_HOST=https://cool.openpo.st OPENPOST_POSTHOG_UI_HOST=https://eu.posthog.com OPENPOST_TELEMETRY_ENVIRONMENT=production ``` `OPENPOST_POSTHOG_API_HOST` is the direct server-side ingestion endpoint. `OPENPOST_POSTHOG_BROWSER_HOST` may be a first-party reverse proxy. Cloud mode defaults to the Hosted service `https://cool.openpo.st` proxy; self-hosted deployments fall back to the direct API host unless the operator sets a proxy. A browser proxy must forward PostHog ingestion and static asset paths, support `GET` and `POST`, preserve the client connection metadata needed for the approved PostHog country and bot transformations, and avoid forwarding OpenPost `Cookie`, `Authorization`, and unnecessary `Referer` headers. Keep `OPENPOST_POSTHOG_UI_HOST` set to the real PostHog UI host. The browser reads its safe runtime configuration from `GET /api/v1/telemetry/config`. This lets one static OpenPost bundle work in the Hosted service and in self-hosted installations without compiling the Hosted service token into every binary. #### Marketing and documentation The marketing and documentation sites are separate static builds. Give both builds the same production project and environment as the app: ```dotenv VITE_POSTHOG_PROJECT_TOKEN=phc_operator_owned_project_token VITE_POSTHOG_API_HOST=https://cool.openpo.st VITE_POSTHOG_UI_HOST=https://eu.posthog.com VITE_OPENPOST_ENVIRONMENT=production VITE_OPENPOST_VERSION=3.4.0 VITE_OPENPOST_REVISION= ``` Use one PostHog production project across the app, backend, marketing site, and documentation so acquisition and product adoption can be analyzed together. Use separate projects for development and staging. Every event includes a stable `surface` and environment so queries can still separate them. Production public-site builds stop with an error when the project token, exact Hosted service proxy host, or EU UI host is missing. The browser rewrites PostHog-owned page lifecycle and Web Vitals URL properties to the route template already captured by OpenPost. It removes query strings, fragments, raw dynamic route values, referrer paths, page titles, resource entries, and element attribution before delivery. Every browser event carries `analytics_mode` so reports can separate persistent identities from daily cookieless hashes. #### Source maps Production source-map upload is opt-in in CI. Set `POSTHOG_SOURCEMAPS_ENABLED=1`, then provide `POSTHOG_PERSONAL_API_KEY`, `POSTHOG_PROJECT_ID`, `POSTHOG_UI_HOST`, and a release identity through `OPENPOST_RELEASE_VERSION` or `GITHUB_SHA`. The personal API key is a CI secret and must never use a `VITE_` prefix. Builds upload hidden source maps and delete them from the deployed output after upload. #### Shutdown and delivery OpenPost creates one backend PostHog client at startup and closes it during graceful shutdown so queued events can drain. Event delivery is best-effort and must never decide authorization, billing access, publication state, or job success. Keep business records in the OpenPost database; telemetry is an observation layer, not a source of truth. #### Production funnel verification An operator with an authenticated PostHog MCP connection creates or updates the saved `OpenPost first-use Activation` funnel in the production project. The funnel uses the ordered event list above, filters to `environment = production`, and excludes marked smoke events so verification does not change product metrics. After changing the journey catalogue or production PostHog configuration, use PostHog MCP to inspect the saved definition and query the result. Send one personless sequence of all eight events through the project's browser-safe ingestion token with a random `distinct_id`, `openpost_smoke = true`, and `$process_person_profile = false`. Confirm through PostHog MCP that the exact smoke identity reaches all eight ordered steps, then retain the insight link, verification time, and smoke ID with the change record. This operator flow uses MCP OAuth for project access and does not require a PostHog personal API key in GitHub Actions. See PostHog's data-collection controls, Go SDK guide, proxy guide, and GDPR guidance for provider-side setup. ### Update Status Source: [https://docs.openpo.st/configuration/update-status.md](https://docs.openpo.st/configuration/update-status.md) OpenPost can show self-hosted instance admins whether a newer stable release is available. Open **Settings → Instance** to see the running version, build revision, latest stable release, and last check time. The feature is read-only. It does not download files, run commands, change containers, or install updates. Use your normal deployment process after reviewing the linked release notes. #### Network and privacy boundary The server makes a `GET` request to the fixed public GitHub endpoint for the latest stable `getopenpost/openpost` release. It sends a generic `openpost-update-checker` user agent and GitHub API headers. It does not send: - the OpenPost version or build - the instance hostname or public URL - user, workspace, or account data - post or media content - cookies, provider tokens, or other credentials Release checks use a three-second timeout and accept at most 64 KiB. Redirects are limited to the same host, and the release link must point to the official OpenPost GitHub repository. Successful checks are cached for 24 hours. Failures retry after 15 minutes. If a cached successful result exists, a failed refresh marks it as stale instead of discarding it. #### Configuration `OPENPOST_UPDATE_CHECK_ENABLED` defaults to `true`. Set it to `false` to disable outbound release checks: ```env OPENPOST_UPDATE_CHECK_ENABLED=false ``` Cloud mode disables release checks regardless of this setting because hosted deployments use their operator-managed release process. The API requires a signed-in browser session for an unscoped instance administrator. API, CLI, and MCP bearer tokens are rejected. ## Operations ### Backups Source: [https://docs.openpo.st/operations/backups.md](https://docs.openpo.st/operations/backups.md) This page is for Hosted service and self-hosted operators planning backup and restore work. #### Hosted service boundary The Hosted service makes daily Postgres dumps and a daily synchronized copy of Cloudflare R2 media in a root-owned directory on the application host. The directory is not public or application-served. The deployment configuration restricts its directory and newly created file modes, validates database compression before an atomic rename, disables SSH password and direct root login, and grants the named operator access through authorized SSH keys and `sudo`. Application users, support requests, and the OpenPost container do not receive backup-directory access. OpenPost does not add a separate application-level encryption layer to each Hosted service backup artifact. Do not describe those artifacts as encrypted. There is no artifact-encryption key to rotate or recover. The application encryption key is still required to use encrypted provider credentials after a restore and is stored separately through the host's secret-management configuration. Transport to R2 uses its HTTPS endpoint; local Postgres dumps and the local media copy remain inside the host boundary. Provider-managed infrastructure controls do not change this application-level statement. Routine database dumps and changed or deleted media versions are pruned by daily jobs after they become more than 14 days old, so removal occurs on the first daily run after that threshold. The current media mirror follows the live bucket; an object removed from the live bucket moves into a dated version directory before that version expires. Operator-created repair, migration, or incident snapshots are not routine backups. They require a stated recovery, security, or legal purpose and a separate review and deletion decision when that purpose ends; the routine filename-based job does not silently delete them. The database-backup, media-backup, and restore-drill units expose their last result and next timer run through systemd. A weekly drill validates the newest compressed database dump, restores it into a uniquely named temporary database, checks the schema and core row counts, checks that a non-empty database media inventory has a non-empty media snapshot, writes root-readable evidence, and drops the temporary database on exit. On 9 August 2026, the controlled host drill passed those database and media-inventory checks. This proves that the tested database and media copy were readable together; it is not proof of a separate encryption layer. Self-hosted operators choose and must document their own storage encryption, access, retention, deletion, monitoring, key recovery, and restore process. If a deployment adds artifact encryption, its restore drill must start from the encrypted artifact and test current and rotated-key recovery before that deployment describes backups as encrypted. You need the database, media objects, and secrets for a usable backup. The exact commands depend on whether you run the self-hosted SQLite/local-storage default or a hosted Postgres/S3-compatible deployment. #### What to back up - SQLite database files or a Postgres dump - Local media directory or S3-compatible bucket objects - Your `.env` file or secret-management equivalent - File ownership and permissions for the runtime directories #### Self-hosted SQLite and local media This is the default self-hosting path. Stop OpenPost first if you want the simplest backup path: ```bash sudo systemctl stop openpost ``` If your deployment keeps SQLite in WAL mode, copy the database together with any `-wal` and `-shm` files that exist. Those extra files can contain committed data that has not yet been checkpointed into the main `.db` file. ##### Database ```bash cp /var/lib/openpost/openpost.db openpost-backup-$(date +%Y%m%d).db cp /var/lib/openpost/openpost.db-wal openpost-backup-$(date +%Y%m%d).db-wal 2>/dev/null || true cp /var/lib/openpost/openpost.db-shm openpost-backup-$(date +%Y%m%d).db-shm 2>/dev/null || true ``` ##### Media ```bash tar -czf media-backup-$(date +%Y%m%d).tar.gz /var/lib/openpost/media/ ``` ##### Secrets ```bash cp /opt/openpost/.env openpost-env-backup-$(date +%Y%m%d) ``` Restart when the backup finishes: ```bash sudo systemctl start openpost ``` #### Postgres-backed deployments For hosted or cloud-mode deployments, back up Postgres with the database tools provided by your host. A plain `pg_dump` is portable and easy to restore: ```bash pg_dump "$OPENPOST_DATABASE_URL" \ --format=custom \ --file="openpost-postgres-$(date +%Y%m%d).dump" ``` For a restore drill: ```bash createdb openpost_restore pg_restore \ --dbname="postgres://openpost:secret@localhost:5432/openpost_restore?sslmode=disable" \ "openpost-postgres-20260518.dump" ``` Confirm that the same or newer OpenPost migrations have been applied to the restore target before you point traffic at it. #### S3-compatible media For S3/R2-style storage, back up the bucket or configure provider-side versioning/replication. A simple object copy is enough for a manual snapshot: ```bash aws s3 sync "s3://openpost-media" "./openpost-media-$(date +%Y%m%d)" ``` For Cloudflare R2 or another S3-compatible endpoint, pass the endpoint URL: ```bash aws s3 sync \ --endpoint-url "$OPENPOST_S3_ENDPOINT" \ "s3://$OPENPOST_S3_BUCKET" \ "./openpost-media-$(date +%Y%m%d)" ``` Back up object metadata and bucket policy if your provider keeps public access, custom domains, lifecycle rules, or CORS outside the object data itself. #### Restore process 1. Stop OpenPost. 2. Restore the database files or Postgres dump. 3. Restore the media directory or bucket objects. 4. Restore `.env` or the equivalent secrets source. 5. Fix ownership and permissions. 6. Start OpenPost. 7. Confirm login, media access, and scheduled-post visibility. #### Automated database restore drill For a Postgres deployment, `scripts/restore-drill.sh` restores a compressed SQL backup into a uniquely named temporary database, checks the schema and core row counts, writes non-sensitive JSON evidence when requested, and drops the temporary database on exit. It refuses to overwrite any database whose name does not match the dedicated restore-drill pattern. Run it on the database host with an explicit backup: ```bash sudo env \ OPENPOST_RESTORE_BACKUP=/var/backup/openpost/openpost_20260722_000000.sql.gz \ OPENPOST_RESTORE_EVIDENCE=/var/backup/openpost/restore-drill-latest.json \ PODMAN_BIN=/run/current-system/sw/bin/podman \ ./scripts/restore-drill.sh ``` Set `OPENPOST_MEDIA_SNAPSHOT` to the local media-backup directory to include its file count in the same evidence. A database-only pass is not proof that uploaded media can be recovered; the output states `media_snapshot=not_checked` when no media snapshot is supplied. ##### Hosted restore test On 22 July 2026, a hosted Postgres backup was restored into an isolated temporary database. Compressed-backup integrity, public schema restoration, and core account, workspace, and post queries passed. The hosted R2 bucket was copied to a separate local snapshot and `rclone check` reported no differences before the same drill verified that snapshot. The temporary database was dropped after validation. Non-sensitive machine-readable evidence is stored on the host at `/var/backup/openpost/restore-drill-latest.json`. ##### Example restore ```bash sudo systemctl stop openpost sudo mkdir -p /var/lib/openpost/media /opt/openpost sudo cp openpost-backup-20260518.db /var/lib/openpost/openpost.db sudo cp openpost-backup-20260518.db-wal /var/lib/openpost/openpost.db-wal 2>/dev/null || true sudo cp openpost-backup-20260518.db-shm /var/lib/openpost/openpost.db-shm 2>/dev/null || true sudo tar -xzf media-backup-20260518.tar.gz -C / sudo cp openpost-env-backup-20260518 /opt/openpost/.env sudo chown -R openpost:openpost /var/lib/openpost /opt/openpost sudo chmod 600 /opt/openpost/.env sudo systemctl start openpost ``` #### Migrate to another server 1. Install the new OpenPost binary or container deployment first. 2. Stop OpenPost on both the old and new server. 3. Copy the database, any `-wal` and `-shm` files or Postgres dump, the media directory or bucket data, and `.env`. 4. Restore ownership and permissions on the new server. 5. Start OpenPost on the new server. 6. Verify provider callbacks, media URLs, and scheduled posts before switching traffic. If the hostname changes, update your reverse proxy, provider callback URLs, and `OPENPOST_MEDIA_URL` before making the new server live. #### Test restore checklist - Can you log in with an existing account? - Do previously uploaded media items load? - Are connected accounts still listed? - Are drafts and scheduled posts present? - Does `GET /api/v1/ready` return `{"status":"ready","database":"ok"}`? - Does `openpost instance health --instance ` succeed against the restored URL? - If the server hostname changed: do provider callbacks and public media URLs still point at the new host? #### Notes - Test restores, not just backups. - Keep database and media snapshots reasonably aligned in time. - Protect backup copies of `.env`: encrypted provider tokens still depend on `OPENPOST_ENCRYPTION_KEY`. - In cloud mode, do not treat a database dump without matching media objects and secrets as a complete backup. ### Container Image Support and Assurance Source: [https://docs.openpo.st/operations/container-image.md](https://docs.openpo.st/operations/container-image.md) The published OpenPost container image supports **`linux/amd64` only**. The CI candidate build names that platform explicitly and restart-smokes that same image before it can be published. The maintained Dockerfile rejects non-amd64 targets. An ARM64 host must use amd64 emulation; a native ARM64 image requires an explicit downstream Dockerfile/source change and its own complete runtime smoke proof. OpenPost does not currently publish or claim support for a `linux/arm64` image. This limit applies to the container image, not every release artifact. See [Single Binary](https://docs.openpo.st/installation/binary.md) and [CLI Installation](https://docs.openpo.st/cli/installation.md) for the architectures available for those artifacts. #### Runtime base `docker/image-policy.json` records the exact Go backend-builder and runtime base references, the runtime support review, the supported image platform, probe paths, and scanner versions. CI builds the canonical frontend once with the repository-pinned Bun and Node versions, tests and retains that directory, then supplies those exact bytes to the Dockerfile as a named BuildKit context. The Dockerfile pins its remaining image inputs by digest and labels the final image with its shipped Alpine base identity. The policy also declares the complete runtime package set, including any minimum security revisions. The Dockerfile installs exactly those package constraints without a blanket `apk upgrade`, so it does not replace files outside the declared dependency set. Alpine resolves permitted security revisions from the pinned base's release branch, and candidate CI records the exact result in the SPDX SBOM and vulnerability report before publication. Alpine's release-branch table is the source for support dates. Dependabot checks the Docker directory every week. A maintainer still reviews each update, updates the policy record when the base identity changes, and requires the complete candidate image gate before merge or release. #### Candidate evidence Candidate CI performs these checks against the final `linux/amd64` image: - starts OpenPost on a clean persistent database volume; - checks `/api/v1/health`, database-backed `/api/v1/ready`, and the image's OCI health status before and after a container restart; - runs SQLite plus a small FFmpeg/FFprobe media operation inside the runtime image; - verifies the embedded release manifest, OCI version, and exact revision; - generates an SPDX JSON software bill of materials (SBOM); - records the full final-image vulnerability report, including lower-severity and currently unfixed findings, with the scanner version pinned in the image policy; - separately blocks fixable `HIGH` and `CRITICAL` findings. CI retains the manifest, SBOM, and full report as a diagnostic artifact before the blocking scan, so maintainers can inspect a failed vulnerability gate without publishing the image. After a successful gate, the candidate artifact binds the registry digest to hashes of the exact release manifest, SPDX SBOM, and full JSON scan report. The tag workflow pulls that recorded digest rather than resolving the mutable SHA tag again. The release manifest, digest evidence, SBOM, and report are attached to the GitHub release draft. They become public only after every release artifact is present and image promotion, deployment, and hosted readiness have succeeded. A successful gate is evidence for that exact image and scanner database at that time. It is not a claim that the image has no vulnerabilities: the report retains non-blocking findings, and the gate does not fail on findings that the scanner marks as having no available fix. #### Probe ownership The image's OCI health check calls `/api/v1/health`. This proves that the HTTP process is alive without turning a data-plane outage into a container restart loop. Traffic gates, deployment rollouts, and dependency-aware monitors call `/api/v1/ready`, which returns `503` when the database probe or required object-storage capability check fails. See [Health Checks](https://docs.openpo.st/operations/health-checks.md) for the complete operator policy. #### Updating the policy When the runtime base or scanner changes: 1. Check the upstream lifecycle and release notes. 2. Update the exact Dockerfile reference and `docker/image-policy.json` together. 3. Run `bun run check -- image-policy` and the normal repository checks. 4. Build and restart-smoke the production image on the declared platform. 5. Require candidate CI to generate the SBOM and pass the final-image scan before publishing. Do not describe a routine base update as an exploit remediation unless a specific exploitable condition has been established. ### Health Checks Source: [https://docs.openpo.st/operations/health-checks.md](https://docs.openpo.st/operations/health-checks.md) OpenPost exposes separate liveness and readiness endpoints. #### Liveness ```txt GET /api/v1/health ``` Expected response: ```json { "status": "ok" } ``` Use this endpoint when you only need to know whether the HTTP process is alive. The published image's OCI health check and the maintained Compose file use it so a database outage does not turn into a container restart loop. Docker Engine records this result as container health. The Compose `restart: unless-stopped` policy reacts when the process exits; it does not restart a running container solely because the health check reports `unhealthy`. An orchestrator or external watchdog may choose to act on that state. #### Readiness ```txt GET /api/v1/ready ``` Expected response: ```json { "status": "ready", "database": "ok" } ``` An S3-backed instance also reports its required object store: ```json { "status": "ready", "database": "ok", "storage": "ok" } ``` Use this endpoint for load-balancer traffic admission, deploy rollouts, and external uptime probes that should fail when a required data-plane dependency is unavailable. It returns `503` when OpenPost cannot run a database probe or the configured S3 bucket fails its bounded capability check. Compose does not remove traffic when readiness fails by itself. Configure the reverse proxy, load balancer, deploy hook, or monitor that owns traffic to call this endpoint. For Kubernetes-style orchestration, use `/api/v1/health` as the liveness probe and `/api/v1/ready` as the readiness probe. A failed liveness probe may restart the process. A failed readiness probe should stop new traffic without assuming that a restart can repair the database dependency. #### CLI check The CLI can check the same public instance from an operator shell: ```bash openpost instance health ``` Use this for deploy validation and operator smoke checks. The command checks both liveness and readiness, and exits non-zero if either probe fails. For remote scripts, point the active CLI profile at the public app URL first: ```bash openpost instance add production https://app.openpost.example openpost instance use production openpost instance health --json ``` You can also avoid saved state and pass the instance URL directly: ```bash openpost instance health --instance https://app.openpost.example --json ``` The JSON output is useful for logs and monitors because it includes the checked instance URL, liveness result, readiness result, and database readiness status. For support snapshots, use diagnostics: ```bash openpost instance diagnostics \ --instance https://app.openpost.example \ --deployment docker-compose \ --provider youtube \ --logs-file ./openpost.log \ --json ``` Diagnostics includes the CLI version, OS/architecture, profile, instance URL, config paths, liveness/readiness/database status, token presence/source, and authenticated user/workspace counts when a token is available. With a token, it also includes account-provider readiness counts, the requested provider status when `--provider` is set, and billing plan/usage state for the active workspace when one is selected. Optional `--deployment`, `--provider`, and `--logs-file` fields capture the deployment method, provider being tested, and a redacted last-100-line log tail. It never prints raw API tokens or server secrets. #### Recommended probes - Container or orchestrator liveness: `GET /api/v1/health` - Load-balancer traffic readiness: `GET /api/v1/ready` - Deploy rollout readiness: `GET /api/v1/ready` - External uptime monitor: `GET /api/v1/ready` - Operator smoke from a shell: `openpost instance health` - Support snapshot from a shell: `openpost instance diagnostics --deployment --provider --logs-file --json` - Mobile app instance setup: the app validates `/api/v1/ready` before saving the instance URL. ### Logs Source: [https://docs.openpo.st/operations/logs.md](https://docs.openpo.st/operations/logs.md) #### Docker Compose ```bash docker compose logs -f openpost ``` #### Docker ```bash docker logs -f openpost ``` #### systemd ```bash journalctl -u openpost -f ``` #### Request reachability Each HTTP request log includes both the requested path and the normalized matched route, such as `/api/v1/publications/:id`. The normalized route lets operators aggregate endpoint use without treating every resource ID as a separate path. The `consumer` field is a low-cardinality hint: `web`, `cli`, `mcp`, `mcp-media`, `n8n`, or `api`. OpenPost derives it from known client User-Agent prefixes, a browser marker, and the MCP route, then discards the full User-Agent from this request log. Callers can set that header themselves, so the field is useful for deprecation and reachability review but is not an authentication, authorization, billing, or audit identity. #### Instance audit evidence Instance administrators can inspect consequential identity, impersonation, billing, provider, MCP, access, Publication, and destructive outcomes in **Settings → Instance → Instance audit**. This is a read projection over each domain's authoritative records. It does not replace application logs or drive domain state. The list and JSON or CSV exports contain opaque IDs, actions, results, times, and allowlisted changed fields. They exclude emails, authored content, secrets, tokens, invitation links, credentials, arbitrary identity details, provider payloads, and provider responses. Access requires a signed-in browser session for a current instance administrator; scoped tokens and non-browser credentials are rejected. Before retiring an API route, review this normalized request evidence for a representative period and inspect known CLI, MCP, frontend, and automation consumers. A static reachability check cannot prove that an external client no longer calls a supported endpoint. When a post fails, start with sign-in callback errors, media link failures, and social network errors. ### Provider Readiness and Launch Gate Source: [https://docs.openpo.st/operations/provider-launch-matrix.md](https://docs.openpo.st/operations/provider-launch-matrix.md) This page is for operators and release reviewers deciding whether a provider-format claim is current. OpenPost keeps provider implementation, setup, evidence, approval, and runtime control as separate facts. An adapter in the binary proves only that code exists. It does not make a provider or format ready, connectable, publishable, or safe to advertise. #### Effective readiness Every decision is for one exact subject: provider app, deployment and provider environment, optional Mastodon instance, account kind, output profile, immediate or scheduled operation, and policy mode. The server projects these facts through one readiness service: | Fact | Meaning | | ------------------ | ---------------------------------------------------------------------------------------------------- | | Configuration | The effective built-in, environment, database, or dynamic app is present. | | Local test | A current normalized local run matches the exact certification contract. | | Live certification | A current real-provider run matches the same contract and subject. | | Approval | The provider app's reviewed tier is current and permits this operation. | | Authorization | The exact connected account grant is valid. Strict certification also requires every recorded scope. | | Policy | The server permits the selected account, format, and policy mode. | | Runtime control | The most restrictive current environment or ledger control is enabled. | The effective state is fail-closed. Actionable states include `approval_required`, `reconnect_required`, `trial_only`, `policy_restricted`, `degraded`, and `expired_proof`. An explicit disable always wins. Missing configuration or certification is reported directly. A healthy state adds no warning or badge. Cloud deployments keep production provider identity and public-claim rules at all times. They enforce missing approval, runtime-control, local-test, live-test, and recorded OAuth-scope evidence as operational blockers only when `OPENPOST_PROVIDER_CERTIFICATION_ENFORCED=true`. The flag defaults to `false` so an evidence rollout cannot disable configured providers or migrated active accounts. Explicit provider disables, missing configuration, revoked or invalid grants, and provider policy restrictions still block operations. Inspect the running server with: ```sh openpost provider readiness openpost provider readiness --json ``` The same projection is returned by `GET /api/v1/provider-readiness`, the account provider catalogue, capability resolution, and the MCP `get_provider_readiness` operation. Capability metadata describes implemented formats; the attached readiness decision says whether the exact operation may run now. #### Current Hosted service claims The canonical `provider-certification/public-claims.json` is the only source for public provider-format certification claims. No posting option has passed our final live check on OpenPost Hosted yet. A social app can appear in OpenPost before it is ready for real accounts. Pinterest, Telegram bot mode, and Discord bot mode currently have no public Hosted claim. Do not advertise their connect, publish, discovery, or analytics operations until current exact-subject live evidence enters the canonical claim projection. Discord incoming webhooks are a separate built-in connection mode and remain supported independently of Discord bot readiness. The release manifest binds that file's exact SHA-256 digest, schema version, and claim count. A build, adapter, configured credential, mocked test, or manual checklist cannot add a claim. The provider pages document implemented OpenPost code paths and configuration requirements. They are not evidence that a particular Hosted service deployment, provider app, account, format, or policy mode is currently live-certified. #### Recording certification evidence Migration 077 creates immutable approval-review, certification-run, certification-check, and runtime-control ledgers. The database rejects updates and deletes on SQLite and PostgreSQL. Records contain normalized outcomes and one-way fingerprints only; never store a token, raw provider response, provider URL, account ID, or operator identity in this ledger. Only an unscoped instance administrator using a signed-in browser session may append facts through these routes. API, CLI, and MCP bearer tokens are rejected: - `POST /api/v1/admin/provider-readiness/approval-reviews` - `POST /api/v1/admin/provider-readiness/runtime-controls` - `POST /api/v1/admin/provider-readiness/certifications` The first test cannot require proof that the test already happened. The privileged `certification_test` execution intent therefore bypasses prior local and live evidence only. It still requires effective configuration, current approval or explicit trial permission, the exact account authorization and scopes, allowed policy, and enabled runtime controls. Queued work preserves the intent and the worker rechecks readiness immediately before each provider call. The intent never makes a result publicly claimable. For each immediate and scheduled subject, record: - connect and exact account-authorization results; - the immediate or scheduled provider result; - the final reconciled provider outcome; - refresh and revoke results, or an explicitly permitted not-applicable reason; - a safe hashed external-result reference; - tested Git revision, contract digest, operator reference, test time, and retest expiry. A later failed run supersedes older passing proof. Changing an app identity, instance, account kind, output profile, operation, policy mode, capability, or contract requirement cannot reuse another subject's evidence. #### Release gate A provider/format may enter the public claim manifest only when all of these are true for the exact production subject: - the production provider app is effectively configured; - approval is current and approved or explicitly not required; - required and granted scope snapshots match the current account grant; - local and live runs are current, complete, and share the current contract digest; - policy is allowed and the effective runtime control is enabled; - each required check passed, with only contract-approved refresh/revoke not-applicable results allowed; - the sanitized projection contains no account, operator, credential, URL, or raw external-result data. Run `bun run check -- provider-certification` before release work. The checked-in zero-claim gate refuses a non-empty manifest unless a trusted ledger projection supplies the current contract digests. This prevents a documentation or adapter change from silently becoming a public readiness claim. ### Troubleshooting Source: [https://docs.openpo.st/operations/troubleshooting.md](https://docs.openpo.st/operations/troubleshooting.md) Diagnose startup, database, media, provider, publishing, and delivery problems on a self-hosted OpenPost instance. #### App does not start Symptoms: container exits or the binary returns immediately. Likely cause: bad env file, missing write permissions, or invalid path settings. How to check: inspect logs and confirm `OPENPOST_DATABASE_PATH` and `OPENPOST_MEDIA_PATH`. How to fix: correct the env vars and ensure the process can write to the target directories. #### Cannot connect a social account Symptoms: auth flow starts but does not complete. Likely cause: callback mismatch, missing app keys, or missing social network access. How to check: compare the callback URL with the social network's developer page and inspect server logs. How to fix: correct the callback, app keys, and requested access. Provider-specific checks: see [Provider Troubleshooting](https://docs.openpo.st/providers/troubleshooting.md). #### OAuth callback mismatch Symptoms: the social network rejects the redirect or returns an invalid redirect error. Likely cause: your public URL or callback path does not match exactly. How to check: verify `OPENPOST_APP_URL`, social network callback settings, and any callback environment variables. How to fix: set the exact public callback URL and restart OpenPost. #### CORS errors Symptoms: browser console shows blocked API requests. Likely cause: incorrect `OPENPOST_APP_URL` or missing `OPENPOST_EXTRA_CORS_ORIGINS`. How to check: inspect browser dev tools and confirm the origin OpenPost is serving. How to fix: update the origin settings and restart the backend. #### Media uploads fail Symptoms: an upload fails before scheduling or a social network rejects the media. Likely cause: file too large, unsupported type, or unwritable media path. How to check: inspect upload responses and verify filesystem permissions. How to fix: correct permissions or reduce media size. #### A social network cannot fetch media Symptoms: text posts work, but media posts to Threads, Facebook, Instagram, or TikTok fail. Likely cause: `OPENPOST_MEDIA_URL` is not public. How to check: try opening a media URL from outside your local network. How to fix: expose OpenPost through HTTPS and set a public media URL. #### Scheduled post did not publish Symptoms: post remains queued or failed. Likely cause: a saved job failed, the social network is down, or the account token is no longer valid. How to check: inspect logs and Activity around the scheduled time, then check the account connection. How to fix: fix the social network or account problem, then retry the failed account. #### Database path is wrong Symptoms: empty app state after restart or startup errors. Likely cause: the database is in the wrong path or in storage that is lost with the container. How to check: confirm the actual file path mounted into the container or host. How to fix: move to a persistent path and update `OPENPOST_DATABASE_PATH`. #### Database locked Symptoms: intermittent write failures or queue delays. Likely cause: filesystem issues or too many competing processes touching the same SQLite file. How to check: confirm there is only one primary OpenPost process using the database. How to fix: keep SQLite on local durable storage and avoid multiple writers. #### Reverse proxy redirects incorrectly Symptoms: auth callbacks or login flows bounce to the wrong host. Likely cause: the proxy host name and OpenPost URL do not match. How to check: compare browser URL, proxy config, and `OPENPOST_APP_URL`. How to fix: use the same public host name and restart OpenPost. #### Wrong public URL Symptoms: pages work locally, but social network callbacks or shared media links fail. Likely cause: localhost or internal hostnames leaked into public-facing settings. How to check: inspect `OPENPOST_APP_URL`, `OPENPOST_MEDIA_URL`, and social network callback entries. How to fix: replace internal URLs with the real public HTTPS domain. ### Upgrades Source: [https://docs.openpo.st/operations/upgrades.md](https://docs.openpo.st/operations/upgrades.md) OpenPost uses SemVer. Read the release notes before every upgrade. A major version can require changes to your setup. Minor and patch releases stay compatible. #### Docker Compose ```bash docker compose pull docker compose run --rm openpost ./openpost migrate docker compose up -d docker compose logs -f openpost ``` The default `all` role also applies migrations for one-container self-hosting. Running the explicit command first makes the release phase visible and is required when `web` and `worker` run as separate processes. #### Checklist - Read the changelog - Back up the database, media, and secrets together - Pull the new image or binary - Restart OpenPost - If you want to lock down signups after setup, set `OPENPOST_DISABLE_REGISTRATIONS=true` before or after the upgrade and restart OpenPost. - Check `/api/v1/ready` - Check scheduled posts, Activity, and recent logs #### Optional account features after upgrade Direct messages, Comments and replies, Analytics, and Grow are optional and per connected account. New accounts start with each feature off. Manage supported features in the Account details drawer under **Settings → Workspace → Social accounts**. Existing accounts keep their current behavior: previous Inbox opt-ins become Direct messages choices, current Analytics and Engagement behavior remains enabled, Grow becomes enabled only where OpenPost already has stored Grow sync state, and other accounts receive explicit off choices. ## API ### API Reference Source: [https://docs.openpo.st/development/api-reference.md](https://docs.openpo.st/development/api-reference.md) OpenPost exposes one HTTP API for the web app, CLI, MCP server, and external automation. The API uses JSON and requires an authenticated session or API token for private operations. Production deployments should serve it over HTTPS. Workspace-scoped operations also require access to the selected Workspace. Use the interactive viewer below to browse operations, request and response shapes, and authentication requirements. For code generation, validation, or another machine client, use the [authoritative OpenAPI JSON](https://docs.openpo.st/openapi.json) generated from the server routes. The OpenAPI JSON is the contract. This page provides navigation and context; it does not copy the full operation catalogue into the documentation corpus. ### Callback URLs Source: [https://docs.openpo.st/reference/callback-urls.md](https://docs.openpo.st/reference/callback-urls.md) This reference is for operators registering provider and identity callback URLs. | Provider | Local callback | Production callback | | ------------- | ---------------------------------------------------------- | ------------------------------------------------------------ | | X | `http://localhost:8080/api/v1/accounts/x/callback` | `https://your-domain.com/api/v1/accounts/x/callback` | | Mastodon | `urn:ietf:wg:oauth:2.0:oob` by default | `urn:ietf:wg:oauth:2.0:oob` by default | | LinkedIn | `http://localhost:8080/api/v1/accounts/linkedin/callback` | `https://your-domain.com/api/v1/accounts/linkedin/callback` | | Threads | `http://localhost:8080/api/v1/accounts/threads/callback` | `https://your-domain.com/api/v1/accounts/threads/callback` | | Facebook | `http://localhost:8080/api/v1/accounts/facebook/callback` | `https://your-domain.com/api/v1/accounts/facebook/callback` | | Instagram | `http://localhost:8080/api/v1/accounts/instagram/callback` | `https://your-domain.com/api/v1/accounts/instagram/callback` | | TikTok | `http://localhost:8080/api/v1/accounts/tiktok/callback` | `https://your-domain.com/api/v1/accounts/tiktok/callback` | | YouTube | `http://localhost:8080/api/v1/accounts/youtube/callback` | `https://your-domain.com/api/v1/accounts/youtube/callback` | | Google login | `http://localhost:8080/api/v1/auth/oidc/google/callback` | `https://your-domain.com/api/v1/auth/oidc/google/callback` | | Instance OIDC | `http://localhost:8080/api/v1/auth/oidc/instance/callback` | `https://your-domain.com/api/v1/auth/oidc/instance/callback` | Mastodon uses the OOB flow by default and exchanges the pasted authorization code through `/api/v1/accounts/mastodon/exchange`. Only configure a normal Mastodon callback URL if you also override `MASTODON_REDIRECT_URI`. Organization OIDC providers use a provider-specific callback and back-channel logout URL. Open **Settings → Organization → Single sign-on** and copy both URLs from the saved provider. Register only those exact URLs with the identity provider. Google login uses OpenID Connect with PKCE and the stable Google subject as the external identity. OpenPost never links an existing user only because Google returns the same email. Sign in to the existing OpenPost account and link Google from **Settings → Personal → Security** instead. ### Product Surface Parity Source: [https://docs.openpo.st/reference/surface-parity.md](https://docs.openpo.st/reference/surface-parity.md) This reference is for contributors and automation authors choosing an OpenPost client surface. OpenPost exposes one backend through several purpose-built clients. “Supported” means the surface has a first-class workflow; all clients still use the same workspace authorization, validation, quota, and audit rules. | Workflow | Web app | CLI | MCP | HTTP API | | ------------------------------------------------- | --------------------------- | -------------------------------------------- | ------------------------------ | ------------------------ | | Sign in and manage account security | Full | Device login and token profiles | OAuth or API token | Full | | List and switch workspaces | Full | Full | Full | Full | | Connect social accounts | Full | Not exposed; use web OAuth/app-password flow | Not exposed | Full | | List and disconnect accounts | Full | Full | List only | Full | | Inspect provider readiness and capabilities | Full | Full | Readiness and provider catalog | Full | | Create, edit, schedule, and delete standard posts | Full | Full | Full | Full | | Create and publish format-first publications | Full | Full | Full | Full | | Upload and reuse media | Full | Full | Upload-by-URL and reuse by ID | Full | | Manage posting-slot definitions | Full | Full | Reads next slot for scheduling | Full | | Review jobs and publication activity | Full | Full | Publication events only | Full | | Review account and publication analytics | Full | Not exposed | Not exposed | Full | | Reply to and moderate provider comments | Full | Supported providers | Supported providers | Full | | Manage billing | Full on hosted instances | Status, checkout, and portal | Not exposed | Full on hosted instances | | Configure provider applications | Operator configuration only | Not exposed | Readiness only | Instance-admin API | The CLI command reference is generated from Cobra, and the HTTP reference is generated from Huma/OpenAPI. CI regenerates both and fails when committed contracts drift. #### Intentional differences - Provider OAuth belongs in a browser because providers require redirects, consent, and sometimes account selection. - MCP advertises compact `search_operations`, read-only `query_operation`, and mutation-only `execute_operation` tools so assistants discover operation schemas on demand while hosts retain a hard approval boundary. - The CLI favors stable IDs, slugs, JSON output, and explicit confirmation suitable for terminals and automation. - The web app owns visual editing, previews, drag-and-drop media, security ceremonies, and provider consent UX. ## Development ### Analytics Architecture Source: [https://docs.openpo.st/development/analytics.md](https://docs.openpo.st/development/analytics.md) Analytics is an optional platform feature, not part of the core publishing adapter. Publishing, analytics, comments, and inboxes use separate interfaces so a network can support only the features its API allows. This page is for contributors changing analytics collection, storage, or presentation boundaries. #### Boundaries `platform.AnalyticsAdapter` is optional and declares account and OpenPost-managed content support plus required scopes. Its methods return normalized counters. Missing keys mean “not reported”; zero means the provider returned a measured zero. Every metric carries a unit, aggregation meaning, provider source, and reporting period where applicable. `platform.AccountContentDiscoverer` is a separate optional seam for bounded provider inventory. Batch measurement remains optional and keys every result by stable provider content ID. The publishing `platform.Adapter` remains unchanged. `platform.CommentAdapter`, `platform.EngagementAdapter`, and `platform.MessagingAdapter` stay separate from it and from analytics. Each read path has its own access checks, cursors, data limits, and audit records. #### Persistence - `analytics_account_snapshots` stores immutable normalized account measurements. - `analytics_rendition_snapshots` stores one total per published Rendition. Thread segment IDs are collected together and OpenPost's own replies are removed from reply totals. - `account_contents` stores the read-only inventory for eligible content published with OpenPost or elsewhere, including bounded normalized title and text, a provider-validated external URL, origin confidence, and an optional exact Rendition link. - `analytics_account_content_snapshots` stores immutable measurements for discovered content. - `account_content_discovery_states` owns opaque provider cursors, backfill coverage, safe failures, cadence, and read budgets. - `account_content_observations` stores normalized provider-neutral events without webhook payloads. - `analytics_sync_states` stores the latest metrics, collection status, safe error code, last attempt and success, next due time, and unchanged streak. Snapshots include workspace and provider identity for scoped queries and deletion. Raw provider payloads, remote media, access tokens, bot credentials, and webhook secrets are intentionally discarded. Discovery job payloads contain only Workspace and Social Account IDs. #### Jobs and cadence The worker handles `analytics_sweep`, `analytics_account_sync`, `analytics_rendition_sync`, and `account_content_discovery`. A partial unique job index prevents duplicate active work for the same subject. The sweep runs every 15 minutes, queues only due subjects, and always schedules its successor. Each discovery job processes one bounded page, commits its cursor with the page, and then requests a continuation. Account checks start on a daily cadence. Rendition checks use 1, 3, 12, and 24 hour bands until day 7. Repeated unchanged results can increase the current interval to 8× its base cadence. Initial account-content discovery targets 90 days and at most 250 items, then runs no more than daily. Provider concurrency, per-account read budgets, stored backoff, and `Retry-After` apply to scheduled and manual refresh alike. X discovery defaults to a zero read budget unless the operator grants one. Permission and unsupported states do not consume provider quota. OpenPost re-evaluates them during sweeps so reconnects and newly added adapter support recover without manual database changes. Rate limits preserve the last successful values and use the provider's retry hint when available. The sweep uniqueness rule applies to pending sweeps. A running sweep can queue its successor, and worker recovery marks a stale running sweep complete when that successor already exists. Do not broaden the sweep index to pending and processing rows without changing this lifecycle. #### API and UI `GET /api/v1/analytics` reads stored data only. It accepts `source=all|openpost|external`; the source, account, sort, range, and offset are bound into the opaque next cursor. It returns whole-account coverage, flat discriminated content references, metric metadata, and server-owned insights calculated before content pagination. Account growth remains explicitly account-wide. Page reads never call a provider. `POST /api/v1/analytics/refresh` requires editor access and reconsiders collection without bypassing provider gates. `POST /api/v1/analytics/repurpose` accepts a Workspace ID, discriminated content reference, and range in the request body. It recomputes bounded evidence and returns a fresh local composer handoff without writing a Publication, changing the source, or calling an AI provider. The API preserves engagement and exposure types. The frontend and mobile declarations are regenerated from Huma through OpenAPI. Analytics is intentionally not a first-class CLI or MCP workflow; their parity documentation continues to mark it unavailable rather than publishing a divergent contract. ### API Tokens Source: [https://docs.openpo.st/development/api-tokens.md](https://docs.openpo.st/development/api-tokens.md) OpenPost API tokens are account credentials for the REST API, MCP clients, the CLI, and automation. Create one token per client in **Settings → Personal → Developer access**, give it the narrowest scope and workspace boundary that works, and revoke it when that client no longer needs access. #### Create and store a token 1. Enter a name that identifies the client or job. 2. Choose a scope and, when possible, one workspace. 3. Choose 30 days, 90 days, one year, or a custom expiration date. The server rejects dates in the past or more than one year away. 4. Create the token and copy the full secret from the one-time result. The full secret is never stored in plaintext and cannot be shown again. OpenPost lists only a short prefix, scope, workspace boundary, creation time, last use, expiration, and active, expired, or revoked status. If the secret is lost, revoke it and create a replacement. An omitted or JSON `null` `expires_at` uses the finite 90-day default. Existing tokens created under older versions may have no expiration; the settings list labels those explicitly. New tokens cannot request an unlimited lifetime. #### REST scopes Use an `Authorization: Bearer ` header. REST scopes are operation allowlists, not route-prefix guesses. Unknown operations and legacy Echo routes fail closed, except for the named instance-hosted upload-session content step returned by the documented create-session operation. `api:read` permits the documented read operations for workspaces, workspace settings, publishing accounts, provider readiness and capabilities, social sets, media metadata and usage, publications and events, validation, and posting schedules. `api:write` includes `api:read` and permits the publication create, edit, rendition, schedule, publish-now, and retry operations; the complete media upload-session flow (create, a returned instance-hosted content URL when applicable, and complete), metadata update, favorite, trash, restore, batch delete, and analysis-retry operations; social-set changes; and posting-schedule changes. It does not grant the older direct or batch upload routes, account administration, billing, identity, token-management, or arbitrary MCP access. The generated [API Reference](https://docs.openpo.st/development/api-reference.md) is the source for paths and request bodies. A `403` from a valid token means its scope or workspace boundary does not permit that operation. #### MCP and CLI scopes - `mcp:read` exposes only query operations through MCP. - `mcp:full` adds MCP operations that change OpenPost or call a social provider. The MCP client still controls its approval prompt. - `cli:full` preserves the CLI and existing automation contract. It has broad REST access, including account- and organization-level commands. A workspace binding limits workspace-owned resources, blocks organization-level resources, and retains account-level abilities. An unbound token owned by an instance administrator may set `intent=certification_test` when starting provider authorization and `execution_intent=certification_test` when queuing publication work. These test intents are separate from the provider-readiness ledger routes under `/api/v1/admin/provider-readiness`, which require a signed-in browser session, as does the rest of the instance control plane. REST scopes cannot call MCP, and MCP scopes cannot be used as generic REST credentials. This separation keeps each credential tied to its real client contract. #### Workspace boundaries A workspace-bound token can use workspace-owned resources only in that exact workspace, subject to the user's current role there. Losing workspace membership also removes that workspace access. Organization-level resources are unavailable to workspace-bound tokens. Account-level operations remain available when the token's scope permits them; for example, workspace binding does not narrow the broad account-level commands in `cli:full`. An all-workspace token follows the account into workspaces joined later and retains the account- and organization-level commands allowed by its scope, so reserve it for deliberate account-wide automation. If an organization requires SSO, its token policy can deny app tokens or require a one-workspace boundary. Creating a token for that workspace requires a current browser-session assurance from one of the organization's approved identity providers. The token inherits that provider and assurance time, cannot use organization-level resources, and is rejected after the configured assurance age. Migration 084 changes older organization-wide token policies to workspace-scoped mode. It does not auto-bind existing unbound credentials; those tokens can no longer access required-SSO resources. Revoke them and issue one assured token per required workspace, or choose the deny policy. The browser CLI approval page lets the user choose a workspace or all workspaces. The backend verifies current membership before saving a bound approval. CLI and MCP approval endpoints require a signed-in browser session, so an existing bearer token cannot authorize a new credential. See [CLI authentication](https://docs.openpo.st/cli/authentication.md) for the device flow. #### Rotation and revocation Create the replacement first, update the client, confirm it works, and then revoke the old token. Revocation is immediate and irreversible. Expired and revoked tokens are rejected before the requested operation runs, and their full secrets never appear in lists, logs, or activity views. ### Architecture Source: [https://docs.openpo.st/development/architecture.md](https://docs.openpo.st/development/architecture.md) This page is for contributors changing OpenPost's system boundaries. #### Frontend - SvelteKit - TailwindCSS - Paraglide - Vitest - Bun #### Backend - Go - Echo - Huma - SQLite by default, Postgres for cloud deployments - Bun ORM HTTP routes are defined with Huma whenever they are part of the typed product API. Echo remains the transport adapter and owns the small number of routes that are not JSON API operations, such as multipart uploads, public media/avatar serving, OAuth/MCP protocol endpoints, and the embedded SPA. Handlers authenticate and validate request boundaries, services own product rules and provider orchestration, and Bun-backed database packages own persistence. Provider API behavior stays in `internal/platform`; provider selection and public-media behavior come from adapter maps and the central capability catalog. #### Background jobs Publishing and other durable work flows through a database-backed jobs table. #### Media Media uses the `BlobStorage` abstraction with local filesystem storage by default and S3-compatible storage for cloud deployments. The Media service owns physical assets, quota accounting, thumbnails, signing, and safe deletion. OpenPost Image Editor is a separate backend handler/service and a dedicated `frontend/src/lib/image-editor/` module. It persists a strict OpenPost document schema, normalized pages, optimistic revisions, extracted media references, recovery history, templates, brand metadata, and one-time composer return tokens. Fabric.js stays behind an adapter and is never the persisted data model. The background-removal runtime and model load only after a user requests removal. OpenPost Video Editor is a frontend-only, local-first system under `frontend/src/lib/video-editor/` and `frontend/src/routes/video-editor/`. A user-selected workspace folder owns project documents, collected media, linked-source records, recordings, derived media, render queues, and final exports. IndexedDB stores only reopenable filesystem handles and their safe metadata. OPFS holds bounded caches, downloaded local models, recorder recovery data, and export scratch files that do not belong in the workspace until they are complete. Deterministic timeline operations, project migrations, atomic filesystem writes, and render-job snapshots protect local state. Preview and export share the same evaluators, rasterizers, audio rules, effects, and backdrop-aware compositor. Mediabunny provides container and packet access, while WebCodecs, Web Audio, canvas, workers, and WebGL2 perform bounded decode, mix, composition, and encode work. The backend has no Video Editor project model. It receives only explicit stock-search requests and final exports sent through the normal Media upload path. #### Deployment The built frontend is embedded into the Go binary for single-binary deployment. #### Client surfaces The web app, CLI, MCP server, and direct HTTP clients share the same backend authorization, validation, quotas, and audit records. They intentionally differ in interaction design. See [Product Surface Parity](https://docs.openpo.st/reference/surface-parity.md) for the supported workflow matrix. ### Backend Source: [https://docs.openpo.st/development/backend.md](https://docs.openpo.st/development/backend.md) The backend uses Echo for HTTP handling, Huma for OpenAPI generation, and Bun ORM for database access. SQLite is the self-hosted default; Postgres is the cloud deployment path. This page is for contributors changing server code, storage behavior, or HTTP boundaries. #### Layering - Handlers - Services - Database/models #### Expectations - Platform logic must stay inside `internal/platform/` - Prefer Bun ORM over raw SQL for normal queries - Use dependency injection patterns from `main.go` ### Background Jobs Source: [https://docs.openpo.st/development/background-jobs.md](https://docs.openpo.st/development/background-jobs.md) This page is for contributors changing durable background work. OpenPost uses durable background jobs stored in the configured database. #### Why - Publishing must survive process restarts - Scheduled work should not disappear when an HTTP request ends - Simple deployments should not need Redis #### Guidance If a feature must continue after the request completes, put it in the jobs table instead of launching an unmanaged goroutine. Workers recover jobs left in `processing` by dead workers after the stale lock window and return them to `pending` without incrementing attempts. Job payload workspace scoping uses database-portable JSON expressions so the same queue paths work on SQLite and Postgres. #### Inspecting Jobs - Use `GET /api/v1/jobs?limit=50&offset=0` for the operator-facing job feed. - The response body stays a raw job array for existing clients. - Pagination metadata is returned through `X-Total-Count`, `X-Limit`, `X-Offset`, `X-Next-Offset`, and `X-Has-More`. - The CLI mirrors this with `openpost jobs list --limit 50 --offset 50`. ### Billing And Usage Foundation Source: [https://docs.openpo.st/development/billing-and-usage.md](https://docs.openpo.st/development/billing-and-usage.md) This page is for contributors changing hosted billing, entitlements, or usage accounting. Hosted service billing uses saved plan limits and durable usage counters. The backend does not call Paddle on normal API requests. #### Current pieces - `packages/plan-catalog/src/catalog.json`: the versioned source for hosted plan names, monthly and annual USD list prices, limits, trial length, card requirement, and amount due when the trial starts. Frontend and marketing code read it directly; `scripts/plan-catalog.mjs` generates the Go projection and checks for drift. - `POST /api/v1/billing/purchase-choice`: creates or revalidates a signed 24-hour continuation for one canonical plan and billing period. The response carries the exact catalogue version, list price, trial, card, due-today, and expiry facts shown during signup. - Hosted password and explicit identity-provider signup require that purchase choice. Password signup carries it through email verification; identity-provider signup validates it before redirecting and rebuilds the stored onboarding return path from the verified claims. Missing, invalid, expired, or mismatched choices fail closed and require a new pricing selection. - `POST /api/v1/billing/welcome`: confirms the first Workspace name and exact signed purchase choice. It creates the Organization, Workspace, Owner memberships, and bound checkout attempt in one transaction. An exact retry returns the same attempt; a replay with different Workspace, plan, period, or return path fails closed. - `GET /api/v1/billing/checkout/{attempt_id}`: resumes the browser-safe checkout configuration for the authenticated user who created the attempt. It never creates another Workspace or checkout attempt. - `GET /api/v1/workspaces/{id}/setup`: projects the current user's applicable setup guidance from the Workspace name, hosted subscription, active destinations, and scheduled or submitted Publications. It stores no separate onboarding state. Organization Owners receive the complete applicable journey; Organization administrators receive authorized billing guidance; Workspace administrators and editors receive only destination and Publication actions they may perform; viewers receive no setup steps or actions; self-hosted deployments receive no Hosted service plan or checkout steps. The composer and Settings surfaces re-read this projection after refresh and return flows. - `entitlements.Service`: evaluates plan limits and keeps self-hosted defaults unlimited. - `usage_counters`: monthly durable counters keyed by workspace, metric, and UTC month. - `billing_customers`: Paddle customer mirrors keyed by organization, with no payment-card data. - `billing_subscriptions`: current Paddle subscription snapshots keyed by organization, fenced by Paddle's `updated_at` value so an older fetch cannot replace a newer recovery state. - `billing_checkout_attempts`: opaque OpenPost checkout attempt mapping, including the selected Paddle price, product plan, billing period, and a unique confirmation key for first-Workspace retries. - `billing_webhook_events`: webhook event ledger for idempotent Paddle processing, including Paddle's event occurrence time and OpenPost's processing time. - `GET /api/v1/organizations/{id}/billing/status`: returns the local subscription snapshot and current-month usage counters for an organization. - `POST /api/v1/organizations/{id}/billing/checkout`: records an opaque checkout attempt and returns the Paddle.js environment, browser-safe client token, selected price, period price map, authenticated email, and OpenPost return URL. - `POST /api/v1/organizations/{id}/billing/portal`: creates a fresh, short-lived Paddle customer portal session after organization-admin authorization. - Workspace billing endpoints resolve the same organization-scoped contract for web and CLI clients. `POST /api/v1/billing/portal` accepts `purpose: "update_payment_method"` to return only the exact subscription's payment-method form. - `POST /api/v1/billing/paddle/webhook`: verifies the raw request body and `Paddle-Signature`, stores each event once, and queues canonical reconciliation. - Cloud mode reads `billing_subscriptions.entitlement_snapshot` for organization-scoped quota checks. - Workspace creation checks `LimitWorkspaces` before inserting a new workspace. In cloud mode, users get a one-workspace bootstrap allowance before checkout; after a subscription is active, workspace creation uses the active organization subscription snapshot. - Provider connection flows check `social_accounts` before inserting a new active social account. - Workspace invitation creation checks `team_members` before issuing a link. The check counts active members plus non-expired pending invitations so a plan cannot over-reserve seats. - Media uploads check `media_bytes_uploaded_monthly` and `media_bytes_stored`; successful new uploads increment monthly uploaded-byte usage. - Scheduled single posts and threads check `scheduled_posts_monthly` before inserting posts or jobs; successful scheduled creates increment monthly scheduled-post usage. - The publishing worker checks `published_posts_monthly` and `provider_write_calls_monthly` before publishing. It records provider write attempts when a provider request is sent and records published posts only after the provider returns success. - When cloud mode has X configured, the publisher creates a durable cost reservation before each X post-create request. It classifies posts with and without URLs using operator-configured prices, then atomically checks confirmed cost plus reservations against a per-workspace monthly safety limit. - A confirmed provider success turns the reservation into an immutable estimated-cost event. A definite provider error releases it. A network, timeout, response-decoding, or other ambiguous result remains reserved without being reported as confirmed billed cost. - `provider_usage_period_counters` keeps reconciled UTC-month confirmed and reserved totals for fast workspace-visible reads. Startup reconciliation rebuilds the open month from immutable events and active reservations; bounded pruning never removes open-month events or reservations. - Provider cost estimates are separate from product subscriptions and entitlement counters. They are a guardrail, not an invoice. X pricing and the X Developer Console remain authoritative. - Self-hosted mode never installs a provider-cost policy, so these counters cannot block self-hosted publishing. #### Hosted X cost guardrail The X guardrail is active only when both conditions are true: 1. `OPENPOST_EDITION=cloud` 2. An X adapter is configured Each reservation and confirmed event uses a hashed idempotency key derived from the workspace, durable job execution, subject, and request phase. Both store the provider, priced operation, units, unit price, estimated cost, and UTC occurrence time. They do not store post text, provider tokens, or provider response bodies. The default per-workspace budget is $5.00 per UTC month. A request that would make confirmed cost plus reservations exceed the budget is rejected before the provider call. `0` blocks all hosted X publishing. Prices and budgets use millionths of a US dollar so calculations stay integer-only: - `OPENPOST_X_MONTHLY_BUDGET_MICROUSD=5000000` - `OPENPOST_X_POST_CREATE_COST_MICROUSD=15000` - `OPENPOST_X_POST_CREATE_WITH_URL_COST_MICROUSD=200000` Review these prices when X changes its pay-per-use catalog. OpenPost exposes confirmed cost estimates and unresolved reserved exposure under **Settings → Organization → Plan & usage**. Reserved exposure protects the safety limit after an ambiguous result, but it is not presented as billed cost. X pricing and the X Developer Console remain authoritative. #### Monthly metrics Initial metrics match the production-readiness plan: - `scheduled_posts_monthly` - `published_posts_monthly` - `media_bytes_uploaded_monthly` - `media_bytes_stored` - `provider_write_calls_monthly` - `social_accounts` - `workspaces` - `team_members` #### Next enforcement points - Approval workflows, shared calendars, or other future team-only features should use the same entitlement service instead of checking plan IDs directly. #### Paddle configuration Hosted/cloud deployments need the complete set below. Set it in the backend process environment or through **Settings → Instance → Configuration → Billing** - this is the single runtime location. In local devenv, use `backend/.env`; in Docker/production, use the backend container environment. Bare `PADDLE_*` without the `OPENPOST_` prefix is never consumed and triggers a startup warning that lists the ignored names without printing values. An instance administrator can intentionally override an existing environment value; the screen names the environment source and labels the override before and after saving. Database-backed secrets are encrypted and write-only, and every saved change requires a server restart. In cloud mode the backend validates all required Paddle variables at startup and fails with the exact missing `OPENPOST_PADDLE_*` names without printing secret values. This integration replaces Whop billing. Before upgrading an existing cloud deployment, create the Paddle products and prices, configure the notification destination at `/api/v1/billing/paddle/webhook`, and migrate each active customer to a Paddle subscription. OpenPost keeps old Whop subscription rows as historical data, but only Paddle subscriptions grant cloud entitlements after this release. - `OPENPOST_PADDLE_API_KEY` - `OPENPOST_PADDLE_ENVIRONMENT` - `OPENPOST_PADDLE_CLIENT_TOKEN` - `OPENPOST_PADDLE_WEBHOOK_SECRET` - `OPENPOST_PADDLE_CHECKOUT_RETURN_URL` - `OPENPOST_PADDLE_STARTER_MONTHLY_PRICE_ID` - `OPENPOST_PADDLE_STARTER_ANNUAL_PRICE_ID` - `OPENPOST_PADDLE_FOUNDER_MONTHLY_PRICE_ID` - `OPENPOST_PADDLE_FOUNDER_ANNUAL_PRICE_ID` - `OPENPOST_PADDLE_PRO_MONTHLY_PRICE_ID` - `OPENPOST_PADDLE_PRO_ANNUAL_PRICE_ID` - `OPENPOST_PADDLE_TEAM_MONTHLY_PRICE_ID` - `OPENPOST_PADDLE_TEAM_ANNUAL_PRICE_ID` - `OPENPOST_PADDLE_AGENCY_MONTHLY_PRICE_ID` - `OPENPOST_PADDLE_AGENCY_ANNUAL_PRICE_ID` `OPENPOST_PADDLE_ENVIRONMENT` must be exactly `sandbox` or `production`. The API-key and client-token prefixes must match the selected environment, which prevents a sandbox browser from sending a checkout to the live catalog or the reverse. `OPENPOST_PADDLE_CHECKOUT_RETURN_URL` is the OpenPost checkout completion URL. It normally points to `/checkout?status=success`; OpenPost then waits for the signed webhook and local subscription reconciliation before granting access. Checkout endpoints return `503` when Paddle is missing required configuration such as `OPENPOST_PADDLE_CLIENT_TOKEN`, an explicit environment, or a monthly/annual price ID. User input errors, such as an unknown OpenPost plan ID, remain `400`. #### Billing data and portal boundary The billing status API reads the local Paddle subscription and customer mirrors plus OpenPost usage counters. It returns the billing contact only when a Paddle customer snapshot contains an email. It does not synthesize missing plan, status, date, limit, contact, or amount facts. OpenPost has no dedicated payment-method, card, invoice, or receipt records. Raw provider payloads remain available for reconciliation and audit, but the product does not parse them into locally managed payment methods or invoice documents. Portal requests accept `manage`, `update_payment_method`, `cancel_subscription`, `invoices`, or `billing_details`. Paddle currently returns purpose-specific subscription URLs for payment-method updates and cancellation. The service verifies that such a URL belongs to the local subscription. Invoices and billing details use Paddle's general portal because Paddle does not expose purpose-specific URLs for them. A missing exact link also falls back to the general URL. The response reports `used_generic_fallback`, and every request creates a new temporary session whose URL is not persisted. The browser sends only the opaque checkout attempt ID in Paddle custom data. It initializes Paddle.js with the server-selected environment, asks Paddle `PricePreview` for localized totals, and mounts Paddle's one-page inline checkout directly in the standalone OpenPost checkout page. OpenPost owns the surrounding plan summary, responsive layout, loading and error states, and a contrast-safe light payment canvas; Paddle continues to own the sensitive payment fields and final localized totals. Set both the minimum and maximum quantity to `1` on every Paddle plan price. OpenPost subscriptions are workspace plans rather than per-seat line items, so allowing Paddle's default maximum of `100` exposes an invalid quantity stepper at checkout. Webhooks may be duplicated or arrive out of order. The worker therefore retrieves the current Paddle customer, subscription, or completed transaction before writing local mirrors. Event `occurred_at` is retained for delivery audit, but it never overrides a newer canonical subscription snapshot. The subscription mirror applies only a strictly newer Paddle `updated_at` value; an identical snapshot is an idempotent no-op, and conflicting payloads with the same provider version fail closed. #### Failed-payment recovery Paddle changes an automatically collected subscription to `past_due` after a failed payment and runs the recovery schedule configured for the Paddle account. OpenPost does not invent a payment deadline because retry timing and the final action can differ by account. It records when the current canonical `past_due` state began, restricts paid-plan actions immediately, and shows the issue to every affected organization member. Organization owners and admins can start recovery in one action. OpenPost creates a new Paddle portal session for each click, requests links for the exact local subscription, and verifies that Paddle returned the same customer. It returns the exact temporary `update_subscription_payment_method` URL when Paddle provides one, or the new general portal URL as a safe fallback. The URL is never stored. Members without billing permission see the same account-wide state and are told to contact an organization owner or admin. Paddle remains the source of truth after the payment method changes. The notice stays visible until a signed webhook job fetches a strictly newer canonical subscription with `active` status. That recovery clears `past_due_since` and restores paid-plan access. A stale or repeated `past_due` event cannot reinstate the failure after the newer active snapshot has been saved. See Paddle's documentation for `past_due` subscription recovery, webhook delivery and ordering, and temporary payment-method portal links. Access is granted only for `active` and `trialing` subscriptions. A scheduled cancellation keeps access while Paddle still reports one of those states. `past_due`, paused, and canceled subscriptions do not grant paid-plan access. API handlers consume the local snapshot only. See the canonical Hosted service pricing for current plan names, prices, trial terms, and limits. ### API and schema compatibility Source: [https://docs.openpo.st/development/compatibility-policy.md](https://docs.openpo.st/development/compatibility-policy.md) This page is for contributors deprecating or removing public API and stored-data contracts. OpenPost does not remove a public API or stored-data contract because repository search reports no caller. API-token clients, older CLI versions, MCP bridges, and automation tools can use a route without appearing in the current source tree. `compatibility-surfaces.json` is the machine-checked retirement registry. It records the owner, exact introduction commit and release, current decision, replacement, migration path, notice state, normalized route evidence, consumer review, and earliest removal facts for each candidate. #### Sunset policy A retained route is supported. It is not deprecated merely because a replacement exists. A route or schema member can become deprecated only when all of these are true: - its replacement is available and the migration path is documented; - the OpenAPI operation or schema property has a `deprecated` marker; - the change is announced in `CHANGELOG.md` and migration documentation; - normalized telemetry review has started for every consumer class. Removal requires at least 90 days and two later stable releases after the announcement. The review must cover `web`, `cli`, `mcp`, `mcp-media`, `n8n`, and other `api` callers. Every class needs recorded no-use or not-applicable evidence, and no known use can remain. A failed or incomplete review keeps the route. The request log's `route` and `consumer` fields provide low-cardinality evidence. The consumer value comes from a caller-controlled user agent, so it is a useful review hint, not proof of identity. See [Logs](https://docs.openpo.st/operations/logs.md) for the exact logging boundary. #### Current decisions The Post compatibility surfaces are removed. Publication and Rendition are the only active authoring aggregates; historical migration files and immutable `legacy_post` / `legacy_post_variant` records exist only while upgrading old databases. The remaining retained and deprecated surfaces below are the `media_cleanup_days` workspace-settings field and a few retained provider, organization, billing, auth, and prompt routes. | Surface | Decision | Reason | | ----------------------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Post routes, Post draft routes, Post variant routes, and the Post schedule overview | Removed | Publication is the only authoring aggregate. No browser or HTTP compatibility route remains. See [Post to Publication migration](https://docs.openpo.st/development/post-publication-migration.md). | | `GET /accounts/mastodon/servers` | Retain | The provider catalogue must prove configured and dynamic Mastodon instance parity first. | | `GET /accounts/{account_id}/destination-options` | Retain | The approved n8n v0.1 contract reserves this selector; implement and migrate it to the paged option route before deprecation. | | `GET /organizations` and `GET /organizations/{id}/team` | Retain | Organization identity, billing, and membership are not equivalent to workspace access. | | Workspace-context and organization-path billing routes | Retain | The app and CLI use the generic routes while organization routes remain part of the admin contract. BILL-003 and BILL-004 must choose one final boundary first. | | `POST /auth/oidc/{provider_id}/logout` | Retain | It is the active local-session revocation and optional provider logout contract. | | `/prompts` routes | Retain | The Writing prompts page and text-and-thread composer are maintained consumers. | | Publication request `intent` aliases | Retain | `creation_preset` is preferred, but no formal alias sunset has started and external clients may still send `intent`. | | Workspace-settings `media_cleanup_days` | Deprecate | The lifecycle is fixed; the compatibility field remains until the announced 90-day, two-stable-release, and consumer-evidence gates pass. | #### Changing the registry Update the registry in the same change as an OpenAPI deprecation or removal. Run: ```bash bun run check -- compatibility ``` The check fails if a retained operation or schema member disappears from OpenAPI, an operation ID drifts, a candidate vanishes from the registry, a deprecation lacks its marker or notices, or a removal is attempted before its time, release, replacement, and consumer-evidence gates pass. ### Connector Protocol 1.0 Source: [https://docs.openpo.st/development/connector-protocol.md](https://docs.openpo.st/development/connector-protocol.md) For connector authors implementing the authenticated Connector Protocol 1.0 text publishing contract. Connector Protocol 1.0 lets a self-hosted OpenPost instance publish text through an operator-run service. Use it for a destination that has a stable API and does not fit a built-in provider. Do not use a connector to load code into OpenPost or to expose an arbitrary user-supplied URL. The connector runs as its own process and keeps all destination credentials. The reference types live in `backend/internal/connectors`. The Directus connector shows a complete server. #### Required behavior A Protocol 1.0 connector must: - expose all six routes below - require the configured bearer token on every route - return JSON and reject unknown request fields - complete a preconfigured connection in one request - declare at least one text-only output profile - treat each `operation_id` as an idempotency key - record or reserve the operation before its first destination write - reconcile an uncertain write through the operation route - return typed problem JSON for failures - keep destination secrets out of responses and logs OpenPost sends `Authorization: Bearer ` and `Accept: application/json, application/problem+json` on every request. JSON requests use `Content-Type: application/json`. Keep responses at or below 1 MiB. #### Publish flow OpenPost follows this order: 1. Read and validate the manifest at startup. 2. Read connector and destination health. 3. Create a Workspace-scoped connection and store the returned account binding. 4. Resolve capabilities for the selected output profile and settings. 5. Store the connector's capability revision with the Rendition. 6. Enter OpenPost's durable provider-write fence. 7. Send one deterministic `operation_id` with the publish request. 8. Store a published result or poll a pending operation. If a transport error occurs after a possible write, OpenPost treats the result as unknown. It does not make an unsafe blind retry. Your connector must make the same operation safe to repeat or expose its result through `GET /v1/operations/{operation_id}`. #### Manifest `GET /v1/manifest` describes the connector without exposing secrets. ```json { "protocol_version": "1.0", "implementation_version": "0.1.0", "provider": { "id": "com.example.articles", "display_name": "Example articles", "description": "Create an article in the configured site." }, "capability_revision": "articles-v1", "connection": { "modes": ["preconfigured"] }, "publishing": { "output_profiles": [ { "id": "example.article", "display_name": "Create article", "profile": "short_text", "intents": ["post"], "content": { "required": true, "max_length": 100000 }, "title": { "required": false, "max_length": 1000 }, "description": { "required": false, "max_length": 10000 }, "media": { "min_items": 0, "max_items": 0 }, "settings": [ { "key": "status", "label": "Status", "help": "Article status", "control": "select", "required": true, "default": "draft", "options": ["draft", "published"] } ] } ] }, "operations": { "polling": true } } ``` Keep `provider.id` stable across installations and releases. It identifies the connector type, not one installed instance. Do not use a built-in provider ID. Change `capability_revision` when a stored Rendition could become invalid or publish differently because you changed a limit, setting, output profile, or destination rule. OpenPost then requires old account bindings to reconnect before publishing. Protocol 1.0 accepts only: - connection mode `preconfigured` - profile `short_text` - intent `post` - zero media items - setting controls `text`, `textarea`, `number`, `select`, `radio`, and `checkbox` IDs may contain letters, numbers, `.`, `_`, `:`, `/`, and `-`. They must start with a letter or number and cannot exceed 128 characters. #### Health `GET /v1/health` checks the connector and any destination service needed to publish. ```json { "status": "ready" } ``` Return a non-2xx problem response when the connector cannot publish. OpenPost quarantines an optional failed installation. A failed required installation blocks startup. #### Connection `POST /v1/connections` receives the Workspace that requested the connection. ```json { "workspace_id": "workspace-id" } ``` Return a stable opaque `connection_ref` and one to 100 accounts: ```json { "state": "complete", "connection_ref": "site/primary", "accounts": [ { "id": "articles", "username": "articles", "display_name": "Primary articles" } ] } ``` `state` must be `complete`. Account IDs must be unique opaque IDs. `avatar_url`, when present, must be an absolute HTTPS URL. Do not return credentials in account metadata. #### Capability resolution `POST /v1/capabilities/resolve` asks whether the connection can use an output profile with the selected settings. ```json { "connection_ref": "site/primary", "output_profile": "example.article", "intent": "post", "settings": { "status": "draft" } } ``` A usable result returns the current revision: ```json { "capability_revision": "articles-v1", "available": true, "constraints": { "collection": "articles" } } ``` If the destination cannot use this profile or these settings, return `available: false` and an `unavailable_reason`. Do not use this route to mutate destination state. #### Publishing `POST /v1/publishes` asks the connector to perform one destination write. ```json { "operation_id": "opaque-deterministic-id", "connection_ref": "site/primary", "capability_revision": "articles-v1", "output_profile": "example.article", "content": "The article body.", "title": "Article title", "description": "A short description.", "settings": { "status": "draft" } } ``` Validate the connection, revision, profile, content, and settings before any external write. Then reserve `operation_id` in durable storage. A repeated request with the same operation ID must return the first operation's current result without creating another destination item. For an immediate result: ```json { "status": "published", "external_id": "42", "external_url": "https://cms.example.com/items/42", "idempotency_ttl_seconds": 31536000 } ``` `external_id` is required for `published`. `external_url`, when present, must be an absolute HTTPS URL. `idempotency_ttl_seconds` tells OpenPost how long the connector keeps the operation safe to repeat. The maximum is one year. For work that continues after the request: ```json { "status": "pending", "provider_reference": "job-42", "poll_after_seconds": 5 } ``` `poll_after_seconds` must fall between 1 and 3600. Set `operations.polling` to `true` in the manifest. #### Operation lookup `GET /v1/operations/{operation_id}` returns the same `published` or `pending` shape as the publish route. Return `404` with a typed problem only when the connector knows that the operation does not exist and no destination write occurred. If a write may have occurred, keep the result pending or return a problem with `outcome: "unknown"`. Never report a possible write as safe to retry. #### Problem responses Use a non-2xx status and typed problem JSON for all failures. `application/problem+json` is the preferred response type. Do not return `status: "failed"` in a successful response. ```json { "title": "Destination rejected the item", "status": 422, "detail": "The configured collection does not accept this status.", "kind": "provider_error", "provider_code": "invalid_status", "action": "Check the collection status values.", "outcome": "rejected" } ``` | Field | Use | | --------------- | -------------------------------------------------------------------------- | | `kind` | Stable connector error class for OpenPost. | | `provider_code` | Stable destination-specific code when one exists. | | `action` | Safe operator action. Do not include a token or response body. | | `outcome` | `rejected` when no write occurred, or `unknown` when it may have occurred. | | `retry_after` | Optional delay in seconds for a safe retry. | Keep `kind` and `provider_code` stable enough for logs and support. Write `title`, `detail`, and `action` for a person who does not know the connector's code. #### Idempotency rules The `operation_id` is the key safety rule in this protocol. 1. Validate everything that can fail without a write. 2. Reserve the operation in durable storage. 3. Start the destination write. 4. Store the destination ID and current state. 5. Return the stored result for every repeat request. 6. Keep an uncertain operation pending until you can reconcile it. A destination with a unique idempotency field can use that field as the operation journal. Otherwise, the connector needs its own durable store. An in-memory map is not enough because scheduled jobs and retries survive process restarts. #### Security rules - Compare bearer tokens without timing leaks. - Do not log authorization headers, request bodies that may contain private post text, destination responses, or secrets. - Apply short request and destination timeouts. - Limit request and response bodies. - Reject unknown JSON fields and extra JSON values. - Validate every ID, URL, field name, setting, and content limit before use. - Do not return arbitrary HTML, JavaScript, Svelte, or raw SVG. OpenPost renders only its own controls from validated manifest settings. - Keep destination access narrow. A connector token should reach only the connector. A destination token should reach only the required destination data. #### Test checklist Before shipping a connector, prove that it: - rejects a missing or wrong bearer token on every route - returns a manifest that OpenPost accepts - fails health when its required destination is unavailable - never exposes an account outside the intended preconfigured connection - rejects an old capability revision before writing - rejects invalid content and settings before writing - returns the same result for concurrent requests with one `operation_id` - returns the same result after the connector restarts - creates one destination item when the first response times out after the write - reports an unknown result without causing a blind retry - redacts tokens, post bodies, and destination responses from logs - keeps every response at or below 1 MiB For operator setup and endpoint policy, see [Custom connectors](https://docs.openpo.st/configuration/custom-connectors.md). ### Contributing Source: [https://docs.openpo.st/development/contributing.md](https://docs.openpo.st/development/contributing.md) This page is for contributors preparing a change to OpenPost. Start with the [development setup](https://docs.openpo.st/development/setup.md), then run the smallest relevant root gates while working and `bun run verify` before a pull request. #### Project rules - Use Conventional Commits. Release versions follow the commit impact described in [Releases and Versioning](https://docs.openpo.st/development/releases.md). - Keep provider API behavior in `backend/internal/platform/` and preserve the shared adapter contract. - Regenerate OpenAPI, TypeScript, CLI, and translation artifacts from their sources instead of editing generated files. - Preserve the static SvelteKit build embedded in the Go binary. - Add notable behavior, configuration, compatibility, migration, and operator changes to `CHANGELOG.md` under `Unreleased`. It is the canonical source for the public changelog and release notes. - Use the shared Shadcn-svelte form primitives in both the app and marketing site. `bun run check -- ui-consistency` rejects visible native form controls outside the shared implementations. - Never commit credentials, provider tokens, local dotenv files, or production data. Repository-specific architecture and agent guidance live in `AGENTS.md`. The root contributor guide contains the short setup and pull-request checklist. #### Public source and artifact contract Marketing pages and maintained documentation are the canonical sources for public content. Their production builds invoke `scripts/generate-agent-surfaces.mjs` to add one explicit Markdown representation per eligible route, the host's `llms.txt`, and the documentation-only `llms-full.txt`. Generated public artifacts stay in ignored build output. Edit the owning page or catalogue instead of a generated file. The marketing route manifest and generated documentation catalogue own route identity and policy. See [production readiness](https://docs.openpo.st/development/production-readiness.md#agent-readable-public-content) for the complete artifact contract, CI behavior, and root verification commands. ### Frontend Source: [https://docs.openpo.st/development/frontend.md](https://docs.openpo.st/development/frontend.md) The frontend is a SvelteKit app using Svelte 5 runes, TailwindCSS, Paraglide for i18n, and typed API access generated from the backend OpenAPI spec. This page is for contributors changing the web app or its shared marketing controls. #### Expectations - Use standard Svelte 5 runes - Keep API calls typed - Preserve adapter-static output because the backend embeds the built assets - Reuse the shared Shadcn-svelte controls from `frontend/src/lib/components/ui/` for inputs, text areas, selects, checkboxes, radio groups, sliders, and related form UI - Keep marketing controls on those same primitives; the marketing SvelteKit project resolves `$lib` to the shared frontend library - Do not add visible native `input`, `select`, or `textarea` elements outside the shared primitives #### Useful commands ```bash bun run dev -- frontend bun run check -- frontend bun run lint -- frontend bun run test -- frontend bun run check -- ui-consistency bun run build -- frontend ``` The cached frontend task owns the compiled files in `frontend/build` and omits the tracked immutable editor model and audio trees from its cache entry. Vite receives a temporary public tree without those assets, so it does not copy the large files before the build links them into the final web output. `bun run build -- frontend` checks the selected image bundle, model, and audio manifests and their declared file sizes and SHA-256 digests, restores those trees from `frontend/static` with hard links when possible, refreshes both generated web trees, validates the complete artifact, then replaces `backend/cmd/openpost/public` atomically. Missing or partial sources fail before an existing artifact changes. ### Developer Docs Source: [https://docs.openpo.st/development/index.md](https://docs.openpo.st/development/index.md) Use these docs when you are changing OpenPost itself: backend routes, platform adapters, frontend components, generated clients, MCP tools, billing infrastructure, database migrations, tests, or release behavior. Developer docs are separate from user-facing docs and self-hosting docs. They can assume repository access, local toolchains, and willingness to run checks. #### Start here - [Development Setup](https://docs.openpo.st/development/setup.md) gets the repo, frontend, backend, and docs running locally. - [Architecture](https://docs.openpo.st/development/architecture.md) explains the main boundaries. - [Contributing](https://docs.openpo.st/development/contributing.md) covers contribution workflow. - [Testing](https://docs.openpo.st/development/testing.md) covers local and CI checks. - [Releases and Versioning](https://docs.openpo.st/development/releases.md) covers SemVer, production releases, and failure handling. #### Backend and API - [Backend](https://docs.openpo.st/development/backend.md) covers Go backend conventions. - [API Reference](https://docs.openpo.st/development/api-reference.md) renders the generated OpenAPI spec. - [API Tokens](https://docs.openpo.st/development/api-tokens.md) documents one-time secrets, expiry, scopes, workspace boundaries, and revocation. - [Background Jobs](https://docs.openpo.st/development/background-jobs.md) covers durable job behavior. - [Platform Adapters](https://docs.openpo.st/development/platform-adapters.md) covers provider integration rules. - [Billing and Usage](https://docs.openpo.st/development/billing-and-usage.md) covers entitlements, usage counters, and hosted billing primitives. #### Frontend, MCP, and launch work - [Frontend](https://docs.openpo.st/development/frontend.md) covers SvelteKit app conventions. - [MCP and ChatGPT App](https://docs.openpo.st/development/mcp.md) covers tool-server and Apps SDK integration notes. - [Production Architecture](https://docs.openpo.st/development/production-readiness.md) records shared hosted and self-hosted architecture, provider verification, and release checks. #### Adjacent docs - If you are using OpenPost through the web app, CLI, or MCP client, start with [User Docs](https://docs.openpo.st/usage/index.md). - If you are deploying or operating an instance, start with [Self-Hosting Docs](https://docs.openpo.st/self-hosting/index.md). ### MCP And ChatGPT App Source: [https://docs.openpo.st/development/mcp.md](https://docs.openpo.st/development/mcp.md) This page is for MCP implementation and protocol details. For setup-oriented user docs, see [Agent-Assisted Publishing With MCP](https://docs.openpo.st/mcp/index.md). OpenPost exposes an authenticated MCP foundation at: ```txt POST /mcp ``` The endpoint is JSON-RPC over HTTP and requires a bearer token: ```http Authorization: Bearer ``` OpenPost accepts MCP `ping` requests and Streamable HTTP JSON-RPC notifications. Notification POSTs such as `notifications/initialized` return HTTP `202 Accepted` with no response body. ChatGPT Apps-compatible clients can also discover and load the scheduler widget resource: ```txt resources/list resources/read ui://widget/openpost-scheduler-v1.html ``` The widget is a self-contained `text/html;profile=mcp-app` resource. The read-only `render_scheduler_widget` tool points at that resource through `_meta.ui.resourceUri` and `_meta["openai/outputTemplate"]`, then passes structured OpenPost data into the widget for rendering. OpenPost emits the standard MCP Apps keys under `_meta.ui` and keeps legacy ChatGPT aliases mirrored under `_meta["openai/..."]`. For example, widget CSP uses camelCase `connectDomains` and `resourceDomains` under `_meta.ui.csp`, while `_meta["openai/widgetCSP"]` keeps the snake_case alias expected by older ChatGPT clients. The render tool is model-visible only; the current widget does not call tools directly. For ChatGPT Apps and other OAuth-aware MCP clients, OpenPost also publishes protected-resource and authorization-server metadata: ```txt GET /.well-known/oauth-protected-resource GET /.well-known/oauth-protected-resource/mcp GET /.well-known/oauth-authorization-server ``` The `/mcp` protected-resource identifier deterministically maps to the metadata path ending in `/mcp` under RFC 9728. The root protected-resource path remains available because MCP authentication challenges name it explicitly. Both paths describe the same `https://app.openpo.st/mcp` resource on Hosted. OpenPost also publishes its experimental MCP Server Card at `/.well-known/mcp/server-card.json` and `/mcp/server-card`. The card describes the real Streamable HTTP endpoint and supported protocol versions. OAuth details remain in the RFC 9728 metadata instead of a card-specific auth object. The advertised tool surface uses progressive discovery to keep model context small. `mcp:full` clients receive `search_operations`, `query_operation`, `execute_operation`, and the Apps widget renderer. `mcp:read` clients receive the same surface without `execute_operation`; search results and prompt discovery are filtered to read-only operations. `search_operations` returns the exact input/output schema, safety annotations, and required execution tool for relevant OpenPost operations on demand. It returns no match for ambiguous mutations or tasks outside OpenPost instead of guessing. `query_operation` accepts only catalog operations guaranteed to be read-only; `execute_operation` accepts only state-changing or external-action operations. Both delegate through the existing authorization, workspace-scope, schema validation, quota, and audit path. Operation documentation omits repeated OAuth and Apps metadata because those details already live on the four advertised descriptors. The scheduler widget remains directly advertised because its OAuth metadata and `_meta.ui.resourceUri` are needed by Apps-compatible clients to load the output template. Previously advertised operation names remain callable for cached clients. The old `search`, `query`, and `execute` aliases also remain callable but are not advertised. New clients should discover operations with `search_operations` and invoke them through the returned `query_operation` or `execute_operation` path. Cached direct descriptors keep their operation-specific safety annotations and do not weaken the generic tool boundary. OAuth-aware clients can start account linking at the browser authorization page, then exchange the returned code for an MCP-scoped bearer token: ```txt GET /oauth/authorize POST /oauth/token ``` The authorization request can ask for `mcp:read` or `mcp:full`; omitted scope defaults to `mcp:full`. The approval page can bind the resulting token to the current workspace. A workspace-scoped token can only list that workspace and MCP tools reject any request whose `workspace_id` targets another workspace. Manual tokens created in Settings support the same optional workspace boundary. Desktop MCP clients can use the local stdio proxy from the CLI module: ```sh openpost --profile local auth login https://your-openpost-host.example openpost-mcp --profile local ``` The proxy loads the same OpenPost CLI profile and token, then forwards MCP JSON-RPC frames to the remote `/mcp` endpoint. It uses the MCP standard's newline-delimited JSON framing on stdin/stdout, accepts legacy `Content-Length` framing from older clients, advertises both Streamable HTTP response types, and forwards the negotiated `MCP-Protocol-Version` on later requests. Recent MCP tool calls are available under **Settings → Personal → Developer access**. The same data is exposed to authenticated API clients at: ```txt GET /api/v1/mcp/activity?limit=20 GET /api/v1/mcp/activity?workspace_id= ``` #### Advertised tools - `search_operations`: accepts a plain-language capability query and returns up to ten matching operation definitions with their exact input/output schemas, safety annotations, and an `executionTool` routing field. - `query_operation`: accepts a read-only `operation` returned by `search_operations` plus its `arguments`. The server rejects every mutation before dispatch. - `execute_operation`: accepts a state-changing or external-action `operation` returned by `search_operations` plus its `arguments`. The server rejects every read-only operation before dispatch so clients can require approval for this tool as a whole. - `render_scheduler_widget`: renders structured OpenPost scheduler data in the ChatGPT Apps widget and stays directly visible for UI resource discovery. For `mcp:read`, `tools/list` omits `execute_operation`, `search_operations` omits mutation results, and direct or cached mutation calls are rejected before dispatch. Read-only connections receive only the `review_schedule` prompt; prompts that create or adapt work require `mcp:full`. Example discovery and execution calls: ```json { "name": "search_operations", "arguments": { "query": "list connected accounts" } } ``` ```json { "name": "query_operation", "arguments": { "operation": "list_accounts", "arguments": { "workspace_id": "workspace-id" } } } ``` Mutation discovery uses the same shape with `"name": "execute_operation"`; clients should use the `executionTool` returned by `search_operations` rather than infer safety from an operation name. ##### Why the delegated tools do not evaluate JavaScript Cloudflare's full Code Mode pattern runs model-written JavaScript in an isolated Worker, blocks direct outbound network access, and exposes only a host-controlled request function. OpenPost's portable Go binary does not currently include an equivalent sandbox or pause/approval runtime. Evaluating model-written code in the application process would create an avoidable security and resource-exhaustion boundary. The current `search_operations`/`query_operation`/`execute_operation` design takes the part that produces the immediate context saving—progressive schema discovery—while delegating each operation to the existing typed handler. A future sandboxed or declarative batch executor can add loops, filtering, and multi-operation composition without collapsing the hard read/mutation safety boundary. #### Discoverable operations - `list_workspaces`: returns the workspaces available to the authenticated user. - `list_provider_catalog`: returns provider launch status so assistants know which platforms are available, need server configuration, or are still planned. - `list_accounts`: returns active social accounts for a workspace. - `list_media`: returns recent workspace media attachments so assistants can reuse existing assets. - `get_provider_readiness`: returns provider configuration, account, app-review, and public-media readiness checks. - `create_publication`: creates a format-first publication with renditions and destination-specific settings. - `list_publications`: lists format-first publications for a workspace. - `get_publication`: returns a publication with its destination renditions and delivery state. - `update_publication`: updates editable source fields, schedule time, and an optional random-delay range while preserving omitted values. - `set_publication_renditions`: replaces a publication's destination-specific outputs and media roles. - `reply_to_rendition`: queues an explicit provider reply immediately or at a requested time. - `validate_publication`: validates a publication before scheduling or publishing. - `schedule_publication`: schedules an existing publication. The saved random-delay range is explicit or inherited from the Workspace, and the resulting Job time is authorized exactly. - `cancel_publication`: cancels a scheduled publication and its pending delivery Job. - `publish_publication_now`: queues an existing publication for immediate publishing. - `list_publication_events`: returns lifecycle events for a publication. - `list_rendition_comments`: lists comments for a published rendition. - `reply_to_comment`: replies to an opaque comment ID returned by `list_rendition_comments`. - `hide_comment`: hides a supported provider comment. - `delete_comment`: permanently deletes a supported provider comment. - `suggest_next_slot`: returns the next free configured posting slot for a workspace. - `upload_media_from_url`: fetches a public HTTP(S) media URL and stores it in a workspace. The directly advertised `render_scheduler_widget` is intentionally outside the delegated operation catalog; clients call it only when they want the Apps UI. #### Registry listing version and compatibility The `version` in the repository's `server.json` belongs to the immutable **Official MCP Registry listing**. It is not the OpenPost application version and it is not the date-based MCP protocol version negotiated during `initialize`. The application reports its release through `/api/v1/version`; each MCP session reports and validates its negotiated protocol version separately. OpenPost changes the registry version only when it publishes a new registry entry for the Hosted service `https://app.openpo.st/mcp` endpoint. Registry versions use stable semantic versioning: - Major: an intentionally incompatible transport, authentication, tool-name, required-input, or result-contract change. - Minor: a backward-compatible tool, prompt, resource, optional input, or result addition. - Patch: metadata, description, example, or other behavior-preserving correction. Every published registry version is immutable. During a coordinated endpoint migration, `server.json` and `launch-kit/listings.md` may identify the same prepared version before publication. The listing must state that it is prepared, name the publication blocker, and preserve the currently published version. After publication, replace that preparation note with the live registry evidence. The repository check rejects ranges, prereleases, a changed Hosted service endpoint, or unexplained version drift. The Hosted MCP endpoint is `https://app.openpo.st/mcp`. Clients configured with another origin must reconnect so the OAuth issuer and resource audience match the canonical endpoint. The registry identity remains `io.github.rodrgds/openpost` after the source repository moved to `getopenpost/openpost`. Registry names identify immutable published records; the `repository.url` field points clients to the current organization-owned source. This policy follows the Official MCP Registry versioning guidance, reviewed 2026-08-09. Clients should use MCP capability negotiation—not registry SemVer alone—to decide whether a specific operation is available. #### Current prompts - `plan_social_post`: guides an assistant from a rough idea to a workspace-aware Publication. - `adapt_platform_renditions`: guides destination-specific copywriting for an existing Publication. - `review_schedule`: guides queue inspection and next-action recommendations without mutating Publications. #### Current scope - Uses the same Bearer authentication path as the CLI and API tokens. - Dedicated `mcp:read` and `mcp:full` tokens can be created in Settings for ChatGPT, Claude, and other MCP clients. Existing `cli:full` tokens also remain accepted by `/mcp` so `openpost-mcp` profiles continue to work. - Publishes MCP protected-resource metadata and returns `WWW-Authenticate` plus `_meta["mcp/www_authenticate"]` challenges for unauthenticated MCP requests. - Rejects untrusted browser origins, non-JSON requests, oversized request bodies, unsupported post-initialization protocol versions, and authenticated tokens with insufficient scope. - Supports MCP `ping` and accepts `notifications/*` messages with HTTP `202 Accepted`, which keeps standard initialization handshakes quiet. - Publishes OAuth authorization-server metadata for public PKCE clients, including `S256`, `mcp:read`, `mcp:full`, and client ID metadata document support. - Provides a browser approval page at `/oauth/authorize` and a form-encoded `/oauth/token` code exchange that mints the requested `mcp:read` or `mcp:full` API token; omitted scope defaults to `mcp:full`. - Validates client metadata redirect URIs for URL-based client IDs, accepts ChatGPT fallback redirects for predefined clients, and binds OAuth-issued MCP tokens to the `/mcp` resource audience. - Advertises and enforces `mcp:read` and `mcp:full` OAuth scopes, with optional single-workspace session boundaries for API-token and OAuth-issued MCP clients. Read tokens never receive `execute_operation` and the server rejects cached or direct mutation calls. - Advertises a guaranteed read-only `query_operation` boundary separately from mutation-capable `execute_operation`, and enforces the catalog classification server-side before operation dispatch. - Documents every advertised and discoverable parameter with examples, uses enums for fixed values, declares required fields and unknown-field behavior explicitly, and validates both operation input and structured output against the advertised schemas. - Adds Apps SDK-friendly `_meta["openai/toolInvocation/invoking"]`, `_meta["openai/toolInvocation/invoked"]`, and `outputSchema` metadata to every tool descriptor. - Exposes a ChatGPT Apps-compatible scheduler widget resource at `ui://widget/openpost-scheduler-v1.html`. - Keeps data tools reusable across MCP clients and attaches widget UI metadata only to `render_scheduler_widget`. - Provides `openpost-mcp` for local stdio clients without duplicating server tool logic. - Advertises MCP prompt templates for common agentic scheduling workflows: planning a post, adapting platform renditions, and reviewing the publishing queue. - Validates workspace membership and account ownership before returning, creating, scheduling, canceling, or uploading data. - Keeps draft iteration agent-friendly: assistants can create, list, update, validate, schedule, cancel, and publish Publications through the canonical Publication tools, set per-destination renditions through `set_publication_renditions`, and inspect lifecycle events. - Validates rendition targets against the Publication destination list so assistants do not create outputs that would never publish. - Rejects media URL fetches that resolve to private, loopback, link-local, multicast, or otherwise local addresses. - Enforces the same scheduled-publication and media-upload entitlement and usage accounting as the web/API paths. - Records MCP tool calls in `mcp_tool_calls` with user, workspace, tool name, success/error status, error message, duration, and timestamp, and exposes recent calls in settings. - Records API-token client ID, name, scope, and token prefix for MCP tool calls when a request uses a dedicated CLI/MCP token, so Settings can attribute activity to ChatGPT, Claude, CI, or another configured client. - Returns structured content so assistants can inspect workspace, account, publication, destination, media, and suggested slot IDs without parsing prose. - Returns provider catalog structured content so assistants can avoid trying to connect or schedule to planned providers before adapters exist. - Lets assistants attach workspace-owned source media to Publications through `media`, while preserving destination-specific media overrides through `set_publication_renditions`. ### Platform Adapters Source: [https://docs.openpo.st/development/platform-adapters.md](https://docs.openpo.st/development/platform-adapters.md) This page is for contributors adding or changing a provider adapter. Provider integrations live under `backend/internal/platform/`. #### Current adapters - `x.go` - `mastodon.go` - `bluesky.go` - `linkedin.go` - `threads.go` - `facebook.go` - `instagram.go` - `tiktok.go` - `youtube.go` - `discord.go` Publishing uses the base `platform.Adapter`. Other features use optional interfaces: - `AnalyticsAdapter` reads account and post results. - `CommentAdapter` and `EngagementAdapter` read and manage comments. - `MessagingAdapter` reads account inboxes after a workspace editor turns sync on. - `AccountSelectionAdapter` lets a user choose a Page, Instagram account, or YouTube channel after OAuth. - `AuthorizationGrantDescriber` identifies the non-secret provider project and execution mode that issued a credential. Keep these interfaces separate. A network can support publishing without supporting every read or moderation action. #### Provider app configuration Provider app credentials are normalized into `platform.AppConfig` before startup builds the adapter registry. Self-hosted installs can use legacy env vars or `OPENPOST_PROVIDER_APPS`; hosted/operator-managed installs can store encrypted rows in `provider_apps`. Instance admins can manage encrypted database rows through: - `GET /api/v1/admin/provider-apps` - `POST /api/v1/admin/provider-apps` - `DELETE /api/v1/admin/provider-apps/{id}` The write API encrypts `client_secret`, never returns stored secrets, and reports `requires_restart: true` because adapter changes are applied on server startup. If `client_secret` is omitted on update, the existing encrypted secret is preserved. #### Account selection Most providers can save a connected account directly after OAuth profile lookup. Some larger platforms need a second step: - Facebook uses this flow to select a Page and save the Page token. - Instagram uses this flow to select a connected Business or Creator account behind a Facebook Page. - YouTube uses this flow to select a channel and preserve the Google refresh token. Adapters for those providers should implement `platform.AccountSelectionAdapter` in addition to the base adapter. The OAuth callback stores encrypted pending tokens in `oauth_account_selections`, redirects with `status=selection_required`, and exposes: - `GET /api/v1/accounts/selections/{connection_id}` for non-secret account/page/channel options. - `POST /api/v1/accounts/selections/{connection_id}/complete` to resolve the selected option and save the final account through `AccountSaver`. Do not store page/channel access tokens in selection options. Keep secrets in the encrypted pending token row or fetch provider-specific page tokens during `SelectAccount`. Pending selections preserve both access-token and refresh-token expiry. The authorizing provider subject is resolved before selection so destinations created from one authorization can share the correct grant without treating a Page or organization ID as the authorizing user. #### Saved authorization grants Migration 073 owns the `oauth_grants` table and backfills every legacy account credential without decrypting or rewriting its ciphertext. `social_accounts.oauth_grant_id` links destinations to the grant; legacy account token columns remain only for rolling-upgrade compatibility and are cleared by the migration and all current writes. The grant records the workspace, provider, provider project, authorizing subject, instance, execution mode, scopes, both expiries, token type and version, consent and validation state, non-secret authorization evidence, refresh lease state, and revocation metadata. `AccountSaver` reuses a grant only when its normalized workspace/provider/project/subject/instance/execution authority matches the new connection. A destination reauthorized under another authority moves to a new grant without rotating credentials for its siblings. Credential refresh is grant-scoped. `TokenManager` acquires an expiring refresh lease, calls the provider outside a database transaction, and persists rotating access and refresh tokens with a token-version compare-and-swap. Sibling requests wait for that version change instead of exchanging the same rotating refresh credential again. Revocation clears both encrypted tokens, increments the version, releases the lease, disconnects the grant's destinations, and cancels its pending refresh job. Every runtime account-to-grant lookup and sibling update also requires the same workspace, so a corrupt cross-workspace reference fails closed. The removal APIs have intentionally different effects: - `DELETE /api/v1/accounts/{account_id}` disconnects one destination only. It returns a conflict for the final active destination so it cannot leave an unused live grant and refresh job. - `DELETE /api/v1/accounts/{account_id}/grant` removes OpenPost's saved credential and disconnects all destinations using it. It does not call the provider's revocation API; clients must describe provider-side revocation separately. SQLite runs with serialized write access, while PostgreSQL uses the same grant-row update as the refresh/disconnect serialization point. Keep the SQLite concurrency tests and the conditional `OPENPOST_TEST_POSTGRES_URL` integration tests green when changing lease, rotation, disconnect, or migration behavior. #### Adding a new platform - [ ] Create `internal/platform/newplatform.go` - [ ] Implement the publishing adapter - [ ] Add only the optional analytics, comment, inbox, or account-selection interfaces the platform supports - [ ] Implement `AccountSelectionAdapter` if OAuth needs page, account, or channel selection - [ ] Implement `AuthorizationGrantDescriber` with a stable non-secret project ID and execution mode - [ ] Register the provider in backend startup - [ ] Add env vars to `.env.example` - [ ] Add the frontend connect flow - [ ] Add the platform icon - [ ] Add provider docs - [ ] Add tests or a manual test checklist ### Post to Publication migration Source: [https://docs.openpo.st/development/post-publication-migration.md](https://docs.openpo.st/development/post-publication-migration.md) This page is for API clients and automation maintainers moving from the legacy Post model to Publications. Post HTTP routes, post-named MCP tools, and the legacy Post authoring model are retired. Publication is the only authoring record for API and automation work. #### What changed - The `posts`, `post_destinations`, `post_media`, `post_variants`, and `thread_drafts` tables are removed after the legacy backfill completes and no Post rows or pending `publish_post` Jobs remain. - Post HTTP routes and the post-named MCP tools (`create_draft`, `list_drafts`, `update_draft`, `set_post_renditions`, `schedule_post`, `schedule_draft`, `get_post_status`, `list_scheduled_posts`, `cancel_post`) are removed. - During an old-database upgrade, immutable `legacy_post` and `legacy_post_variant` records map migrated IDs to Publications. They are migration data only and do not expose a browser or HTTP compatibility route. - Historical migration files remain so an older database can upgrade in place. They translate legacy rows and non-terminal publishing Jobs into Publications, Renditions, and authorization receipts before the final schema drops the legacy tables. #### Field mapping | Legacy Post field | Publication replacement | | --------------------------------------- | ------------------------------------------------------------- | | `id` | `publication_id`, then call `/publications/{id}`. | | `content` | `source_text` and the first segment `body`. | | `thread_draft` or `/posts/thread` items | `creation_preset: "thread"` with one segment per thread item. | | `social_account_ids` | One Rendition per destination `social_account_id`. | | `media_ids` | Segment or Rendition `media` entries. | | Post variants | Publication Renditions. | | `scheduled_at` | `POST /publications/{id}/schedule` after the draft is saved. | | `random_delay_minutes` | Publication `random_delay_minutes`. | | `status` | Publication lifecycle status plus each Rendition status. | #### Route mapping | Removed surface | Replacement | | ------------------------------ | ---------------------------------------------------------------------------------- | | `POST /posts` | `POST /publications`, then `POST /publications/{id}/schedule` when scheduling. | | `GET /posts` | `GET /publications`. | | `GET /posts/{id}` | Use the migrated `publication_id`, then `GET /publications/{id}`. | | `PATCH /posts/{id}` | `PUT /publications/{id}` or schedule and cancel endpoints. | | `DELETE /posts/{id}` | `DELETE /publications/{id}` with `expected_revision`. | | `POST /posts/draft` | `POST /publications`. | | `PUT /posts/{id}/draft` | `PUT /publications/{id}`. | | `/posts/{id}/variants` | `GET /publications/{id}` and `PUT /publications/{id}/renditions`. | | `GET /posts/schedule-overview` | `GET /publications` with `calendar_from` and `calendar_before`, then group by day. | #### CLI and MCP The CLI keeps the friendly `openpost post` and `openpost thread` command names, but every command creates, reads, and mutates Publications and Renditions through the canonical API. Use the returned Publication IDs in scripts. MCP exposes canonical Publication tools only: `create_publication`, `list_publications`, `get_publication`, `update_publication`, `set_publication_renditions`, `reply_to_rendition`, `validate_publication`, `schedule_publication`, `cancel_publication`, `publish_publication_now`, and `list_publication_events`. ### Production Architecture and Checks Source: [https://docs.openpo.st/development/production-readiness.md](https://docs.openpo.st/development/production-readiness.md) OpenPost uses one product core for the Hosted service and self-hosted deployments. This page records the current architecture and the checks needed before a release or public campaign. #### Product and public sites - **OpenPost** is the product name. - The official operated product is the **Hosted service**. - `openpo.st` is the marketing site. - `docs.openpo.st` is the docs site. - `app.openpo.st` is the Hosted service. - The self-hosted server uses the same AGPL application code. #### Shared architecture - `OPENPOST_EDITION=selfhost|cloud` selects the server mode. - Self-hosted mode uses SQLite and local media by default. - Cloud mode requires Postgres, S3-compatible media storage, and Paddle billing settings. - The API keeps normal database work portable across SQLite and Postgres. - Durable database jobs handle publishing, media work, token refresh, analytics, comments, and inbox updates that must survive a restart. - The built SvelteKit app is embedded in the Go server. Keep production secrets, social app keys, monitoring, backups, and private operator scripts outside this repository. #### Billing and limits - The Hosted service opens Paddle's one-page checkout inside OpenPost. Paddle acts as Merchant of Record for payment processing, tax, receipts, refunds, and subscription management. - OpenPost saves current Paddle customer and subscription mirrors plus plan limits in its own database. Normal API requests do not call Paddle. - Limits cover workspaces, members, social accounts, posts, media, schedules, and provider writes. - Self-hosted mode has permissive defaults unless the operator changes them. - The Hosted service creates one workspace before checkout. See the canonical pricing page for current plan and trial terms; an active or trialing membership is required to connect social accounts, upload media, schedule, or publish. - The public prices and limits live in `marketing-site/src/routes/_marketing.ts`. #### Social networks The current publishing adapters are X, Mastodon, Bluesky, LinkedIn, Threads, Facebook, Instagram, TikTok, YouTube, and Discord. - Bluesky and Discord need no shared server app keys. - Mastodon can use set server apps or create an app for a public server during connection. - Facebook, Instagram, and YouTube let the user choose the Page, account, or channel after OAuth. - Threads, Facebook, Instagram, and TikTok need public HTTPS media links for server-side media fetches. - Each account and format still needs a live test. Working code and server setup do not prove that a social network will accept a real post. Analytics, comments, and inbox reads use optional adapter interfaces. They do not run inside the core publishing interface. Page loads read saved data; background jobs call social network APIs. #### Posts and media - Publications are the user-visible post list. - Post, Thread, Story, Short video, and Video are creation presets in one publication composer and use `/publications/:id`. - Social Sets provide reusable account groups. Publications snapshot the chosen destinations. - Each selected account can have its own format, text, media, settings, and schedule override, with per-field inheritance from the shared source. - Schedules and current status stay on the grouped publication and its independently published renditions. - Media uses local storage or S3-compatible storage through `BlobStorage`. - Direct S3/R2 uploads use a signed browser upload when the file fits one request. Larger files stream through bounded multipart uploads. - OpenPost Image Editor saves still-image designs, pages, templates, brand items, history, and media links. Its saved document format does not depend on Fabric.js. #### API, CLI, and MCP - The web app, CLI, MCP, and direct HTTP clients share backend access checks, plan limits, validation, jobs, and audit records. - Remote MCP is available at `/mcp`. - The local `openpost-mcp` process forwards standard input and output messages to that endpoint. - MCP and API tokens can be read-only or full-access and can be limited to one workspace. - MCP exposes a small search tool plus separate read and write execution tools. The server checks every operation again before it runs. - Settings shows recent MCP tool calls and lets users remove tokens and OAuth grants. #### Public copy and docs - `CHANGELOG.md` is the source for the public changelog and GitHub release notes. - Marketing includes product, platform, pricing, security, self-hosting, trust, changelog, and free-tool pages. - The sitemap must include every current public page, platform guide, and tool. - User docs explain the product. Self-hosting docs explain server work. Developer docs explain the code and contracts. - Keep claims about access, limits, app review, and live tests with the relevant social network page. ##### Agent-readable public content The marketing production build generates a `.md` representation for every static product, pricing, platform-index, tool-index, browser-tool, FAQ, security, trust, self-hosting, developer, about, contact, changelog, and legal page. The documentation build generates one for every ordinary maintained page. Canonical HTML advertises each available representation, the discovery index, and the changelog Atom feed; sitemaps list only canonical HTML pages. The marketing route manifest owns stable page titles, descriptions, canonical URLs, representation groups, and discovery classes. The marketing `llms.txt` states when OpenPost is and is not a fit, prioritizes the product overview, features, pricing, platform index, FAQ, security, trust, self-hosting path, developer entry point, operator identity, contact route, and documentation, then links directly to the OpenAPI contract, CLI, MCP, and agent-assisted workflow. The browser-tool index is optional, and platform detail pages appear in their own optional section. Changelog and legal representations remain available at explicit `.md` URLs but are not listed in `llms.txt`. The marketing and documentation origins use `robots.txt` and a `Content-Signal` response header to allow public-content search, AI input, and model training. The directives stay inside the wildcard user-agent group and use only fields supported by the current Content Signals policy. Cloudflare managed robots and verified-bot rules can replace or block that origin policy, so live acceptance must verify the final response and representative search, training, and user-action crawler identities after each policy change. This permission covers public marketing and documentation content only and does not expose authenticated application data. Every marketing page emits one JSON-LD graph that joins its canonical page to the OpenPost website, software application, and the real Hosted service operator. Do not add invented organizations, postal addresses, phone numbers, reviews, ratings, or offers. The public changelog also emits a dated Atom feed built from stable releases in the canonical repository changelog; `Unreleased` work never appears in the feed. The generated documentation catalogue is the checked-in contract shared by both public builds. It derives each page's title and concise description from the maintained Markdown and records its canonical route, representation policy, discovery class, and full-corpus membership. `bun run check -- social-images` rejects drift through the root verification interface. An ordinary documentation prose change runs documentation checks only; a catalogue change runs both public surfaces. The documentation `llms.txt` links to explicit Markdown entry points for the user guide, providers, CLI, MCP, installation, self-hosting, configuration, operations, API, and development. It links to the API guide and authoritative OpenAPI JSON rather than converting the OpenAPI operation catalogue. MCP discovery remains MCP and JSON-RPC. The API-reference page keeps its interactive viewer but also maintains useful no-JavaScript guidance and a direct OpenAPI JSON link. The generated Nix module include expands into the page representation during the owning build. The marketing build also publishes the owner-declared integration inventory, an RFC 9727 API catalog, a byte-identical OpenAPI document, the current experimental MCP Server Card, and an ARD manifest. The API catalog lists the HTTP API and MCP endpoint as separate items. The app origin publishes the same catalog with self-host-aware URLs. All Link response headers use registered relation names, so sitemap discovery stays in `robots.txt` instead of an unregistered `sitemap` relation. OpenPost publishes the complete `openpost-cli` Agent Skill as a deterministic archive. The Agent Skills index uses the current draft schema and includes the archive's SHA-256 digest. The build regenerates both files from `skills/openpost-cli`, so a skill change cannot leave a stale digest. The ARD manifest points to that archive, the OpenAPI document, and the MCP Server Card. Authentication metadata stays on `app.openpo.st`, the origin that protects the MCP resource. Marketing does not mirror or redirect the RFC 9728 well-known path. OpenPost does not publish an A2A Agent Card because it does not implement an A2A protocol binding. It also does not register marketing-page WebMCP tools, publish DNS-AID records, or claim the separate auth.md agent-registration protocol. Add those only after OpenPost owns the matching runtime behavior and the relevant draft is stable enough to support. `scripts/generate-agent-surfaces.mjs` owns the shared generation and validation contract. `scripts/public-agent-discovery.mjs` owns generated marketing discovery artifacts. Marketing representations preserve semantic prose, links, tables, informative images, provider limits, and browser-tool explanations from prerendered `
` content while excluding navigation and interactive controls. Documentation representations start from maintained Markdown, expand controlled in-tree includes, retain headings, prose, lists, tables, code, and supported containers, normalize supported raw HTML, remove links to the private app, and resolve public links and assets against the canonical page. Browser-tool source marks the canonical explanation with `data-agent-include` and the unusable interactive region with `data-agent-exclude`; other pages may use the exclusion annotation for illustrative or duplicate responsive content. Unknown meaning-bearing markup, unresolved or unsafe includes, metadata drift, duplicate outputs, unknown public page targets, invalid rendered fragments, private routes, and representations over 256 KiB without a reviewed catalogue exception fail the build. External targets remain the source owner's responsibility. Both build outputs include a Cloudflare Pages `_headers` contract. Explicit `.md` files declare `text/markdown; charset=utf-8`; discovery and corpus text files declare `text/plain; charset=utf-8`. The build adds `Vary: Accept` to every catalogue-owned canonical HTML path and explicit Markdown artifact so an enabled Cache Rule can separate the two representations. These origin headers do not enable canonical-URL content selection. Optional edge selection remains a separate operator action. The documentation build also publishes `llms-full.txt`, an OpenPost convenience artifact rather than part of the llms.txt v2 proposal. Catalogue metadata selects each page, records exclusions with reasons, and assigns included pages to stable sections. The corpus preserves one source link per page while removing repeated representation metadata and generated warnings. It excludes OpenAPI bodies, legal notices, generated CLI reference repetition, and every explicit catalogue exclusion. The build warns when the corpus exceeds 1 MiB and fails when it exceeds 2 MiB. Each site invokes only its own projection, and generated files stay in ignored build output. The production-artifact contract enumerates every manifest and catalogue route. It requires one canonical HTML file, one explicit Markdown file, useful prerendered content, identity and discovery metadata, absolute links, bounded output, no stale aliases, and no exposed framework state. It also proves canonical plan values, provider facts, legal text, browser-tool explanations, HTML-only sitemaps, curated indexes, corpus policy, and repeatable generation. Shared generator and catalogue changes plan both public builds. Ordinary documentation prose plans only documentation, and each public build cache includes the shared generator, catalogue dependency, and complete output directory. Run `bun run check -- public-routes` for the production-artifact contract and `bun run check -- release-version` for selective CI planning. Then use `bun run build -- marketing` and `bun run build -- docs` to inspect the exact production artifacts. This content covers public product and operating information only. Do not add authenticated application state, Workspace data, OpenAPI conversions, or MCP protocol conversions to these outputs. #### Verification order 1. Update behavior, generated contracts, tests, and public docs together. 2. Run `bun run doctor` before broad or release work. 3. Run scoped root checks while editing, then `bun run verify`. 4. For visible changes, run the related app, docs, or marketing browser suite at desktop and phone widths. 5. Before a public campaign, complete the [Launch Verification Matrix](https://docs.openpo.st/operations/provider-launch-matrix.md) for each account and format. 6. For a production release, follow [Releases and Versioning](https://docs.openpo.st/development/releases.md) and verify the workflow, release, deployed revision, and public readiness. ### Releases and Versioning Source: [https://docs.openpo.st/development/releases.md](https://docs.openpo.st/development/releases.md) OpenPost follows Semantic Versioning 2.0.0 and derives the normal release bump from Conventional Commits. | Commit impact | Version change | Example | | -------------------------------------- | -------------- | ---------------------- | | Backward-compatible fix or maintenance | Patch | `v1.27.9` → `v1.27.10` | | Backward-compatible feature | Minor | `v1.27.9` → `v1.28.0` | | Breaking API or product change | Major | `v1.27.9` → `v2.0.0` | `feat:` selects a minor release. A `!` after the commit type or a `BREAKING CHANGE:` footer selects a major release. When neither appears, a release advances the patch version. #### Production release Run the release from an audited, up-to-date `main` worktree. Uncommitted work is allowed when the command includes its Conventional Commit message: ```bash bun run release -- plan bun run release -- preflight bun run release -- prod "fix: describe the shipped change" ``` The `plan` subcommand inventories every path changed since the latest release, plus staged, unstaged, and untracked work, before anything is written. `release-surfaces.json` assigns each maintained path to one or more release surfaces or a reasoned exemption; both release planning and CI fail when a new path has no owner. Pull-request CI uses that registry to run only affected runtime, site, browser, security, and image checks. Pushes to `main` still run the complete candidate matrix. The `preflight` subcommand checks the worktree, GitHub access, required workflows and deployment configuration, and current production readiness. `bun run release -- check` is the bounded local release gate: generated and type checks first, then formatting, lint, and non-browser unit tests in parallel, without production builds, Chromium component tests, browser suites, race tests, security scans, or Docker. The `prod` subcommand promotes `CHANGELOG.md`, runs the release, changelog, and provider-certification contracts locally, then commits and pushes `main`. GitHub CI is the correctness authority for the immutable candidate SHA, and the command waits for that matrix before creating a tag. Use `bun run release -- check-full` only when an explicit exhaustive local rehearsal is useful; it retains the build, race, security, browser, and production-image gates and the 24-hour exact-worktree stamp. The production-image check enforces a 20 GB budget for unused local BuildKit cache and targets at least 20 GB of host free space. Inspect or enforce those limits directly with `devenv shell -- docker-cache-status` and `devenv shell -- docker-cache-prune`. Set `OPENPOST_DOCKER_CACHE_MAX_STORAGE` or `OPENPOST_DOCKER_MIN_FREE_SPACE` to change them. The prune command does not delete images, containers, or volumes. On a 16 GiB Mac, configure Docker Desktop with 10 GB memory and 4 GB swap. The production frontend build has been verified with that allocation and can be killed by the VM with Docker's 8 GB memory and 1 GB swap allocation. Preflight rejects a macOS Docker VM below 9.5 GiB; `OPENPOST_DOCKER_MIN_MEMORY_GIB` is available only for a host-specific, proven override. At the candidate boundary, CI derives a versioned release manifest from the prepared `CHANGELOG.md` release section and the exact Git SHA. The manifest contains the stable SemVer and full revision. CI embeds that same manifest in the image, stamps both OCI labels and server build values from it, and verifies the labels, embedded file, `/api/v1/version`, SQLite, FFmpeg/FFprobe, liveness, readiness, and the OCI health status while restart-smoking the image against a clean database. The candidate job also creates an SPDX SBOM and runs the pinned final-image vulnerability scan described in [Container Image Support and Assurance](https://docs.openpo.st/operations/container-image.md). CI publishes the verified `linux/amd64` image once as `sha-` and records the resulting registry digest plus hashes of the manifest, SBOM, and scan report in the same candidate artifact. The tag workflow downloads that artifact from the exact successful CI run, verifies its hashes, requires the manifest version and revision to match the tag, and creates or reuses a GitHub release draft with the canonical changelog notes. A rerun accepts only a matching draft whose existing assets are complete uploads from the expected set. An already public release, changed notes, an unexpected asset, or any other inconsistent state stops the workflow. The workflow does not automatically delete a failed draft. The draft receives the release manifest, digest evidence, SBOM, full scan report, three server binaries, four CLI binaries, four MCP binaries, and the Android APK. Candidate CI builds the unsigned Android APK once, records its SHA-256 digest, and retains both files under the exact revision. The tag workflow verifies that digest and only signs the retained APK when signing credentials are configured; it does not rebuild the frontend or Android project. Each artifact job checks that the release is still a draft before it uploads. Image promotion waits for every artifact matrix and the complete expected asset set, then copies the recorded digest to the release tag and `latest` without rebuilding. The signed deployment hook receives that digest, validates the candidate against production configuration and mounted secrets, and automatically restores the previous image if readiness fails. Hosted verification then requires public readiness plus `/api/v1/version` reporting both the release tag and exact revision. Only the final job rechecks the exact asset set and canonical notes and publishes the draft. No public GitHub release is announced while an artifact, promotion, deployment, or readiness gate is still pending. Use `bun run release -- prepare ""` when you want to stop after the exact SHA has passed local and hosted checks. Finish later with `bun run release -- promote `. `bun run release -- status` compares the local SHA, candidate CI run, and public production revision. `CHANGELOG.md` is the release-history source of truth. Add notable work to `Unreleased` while implementing it. Do not edit the public marketing changelog or GitHub release notes separately; both are generated from the canonical file. `bun run check -- changelog` validates the structure before release. Use `RELEASE_BUMP=minor|major` only to raise the inferred impact for an intentional release boundary. `RELEASE_VERSION=vX.Y.Z` is reserved for an explicit version-line correction or migration. Overrides cannot lower the version required by the commit history. #### Version-line correction Historical OpenPost tags advanced the patch component for most releases and later reset to `v1.1.0`, even when the releases contained backward-compatible features. The reconstruction classified the Conventional Commit subjects in each `previous tag..tag` cohort, then applied the highest impact in that cohort. Replaying the published releases from the first stable line gives 27 feature-bearing release cohorts and seven patch-only releases after the last feature cohort, with no published breaking change. The code shipped as `v1.1.22` therefore maps to `v1.27.7`. The versioning and documentation correction was the next patch-only change, `v1.27.8`, but that tag failed preflight before GitHub release creation or deployment. Following the immutable-tag failure policy, `v1.27.9` fixes the clean-checkout check and becomes the first published release on the corrected line. Future releases continue from there using the rules above. #### Failure policy Never move or reuse a published tag. A failed pre-publication attempt may be rerun against the exact unchanged tag after a transient or infrastructure failure; it reuses the matching draft. If the source or release definition must change, fix forward with a new SemVer tag and leave the failed draft available for diagnosis until a maintainer decides how to handle it. A release is complete only after the workflow succeeds, the GitHub release is public with the exact expected assets, `/api/v1/ready` succeeds, and `/api/v1/version` reports the stable tag and exact tagged source revision. ### Development Setup Source: [https://docs.openpo.st/development/setup.md](https://docs.openpo.st/development/setup.md) This page is for contributors setting up the OpenPost repository. OpenPost's root Devenv configuration pins Go, Bun, Node, and the supporting tools used by the repository. On Hermes, provision Nix, Devenv, direnv, and the direnv shell hook through the durable host configuration so they return after a reboot; do not install project tools globally. #### Enter the project environment ```bash git clone https://github.com/getopenpost/openpost.git cd openpost direnv allow devenv shell -- setup ``` `setup` runs the frozen `install`, then creates local environment state. `install` runs `bun install --frozen-lockfile` and downloads the backend and CLI Go modules. Dependency and build caches live under the ignored `.devenv/state/` directory in the checkout, so a NAS-hosted clone does not depend on `/tmp` state. It copies `backend/.env.example` only when `backend/.env` is missing; rerunning it never overwrites local credentials. The backend reads `backend/.env` with its non-executing dotenv loader. Do not `source` or `eval` dotenv files, and do not put credentials in Nix expressions. Use Devenv only to enter or repair the environment: ```bash devenv shell -- setup ``` After direnv loads at the next shell prompt, run repository commands from the root: ```bash bun run dev ``` #### Commands ```bash bun run format bun run format:check bun run lint bun run check bun run test bun run build bun run verify bun run release -- plan ``` The `format`, `format:check`, `lint`, `check`, `test`, and `build` commands accept one optional scope after `--`: `frontend`, `backend`, `cli`, `marketing`, or `docs`. For example, use `bun run check -- frontend` while changing the app. Omit the scope for the complete repository gate. Use `bun run test -- e2e`, `e2e-app`, or `e2e-docs` for browser suites. Focused repository policies use `bun run check -- `, such as `bun run check -- contracts`. The release subcommands are `plan`, `preflight`, `check`, `check-full`, `status`, `prepare`, `promote`, and `prod`; see [Releases and Versioning](https://docs.openpo.st/development/releases.md). Use `cache-status` and `cache-prune` to inspect and enforce the 2 GiB Turbo task-cache cap and the daily 4 GiB default cap on the shared Go build cache. Root commands use the OpenPost directory under the operating system's user cache, so linked worktrees share entries and one total limit, including inside Devenv. Finite root tasks opportunistically enforce the Turbo cap after each run, keeping the newest complete entries. Automatic maintenance skips pruning when another task or maintenance pass is active instead of waiting for it. `dev` does not scan or prune the shared cache during startup. `devenv shell -- cache-prune` waits and enforces the cap explicitly. One worktree still serializes its finite root tasks because they share generated files. Set `OPENPOST_TURBO_CACHE_DIR` to choose a different location or `OPENPOST_TURBO_CACHE_MAX_MIB` to change the limit. Frontend Turbo entries omit the immutable editor model and audio trees. A cache restore links the current tracked files into the complete frontend artifact before Go packaging, and generated SvelteKit, Go embed, and Android trees share those files when the filesystem supports hard links. CI keeps its Turbo cache ephemeral because exact-run source-map uploads must still execute, and jobs that do not build the application omit the large editor trees from their partial checkout. Use `docker-cache-status` and `docker-cache-prune` to inspect Docker storage and bound unused BuildKit cache without deleting images, containers, or volumes. Local backend builds reuse the content-addressed Go cache; clean CI runners still compile the release candidate from their exact checkout. Development runs, tests, vulnerability scans, and lint use the `dev` build tag so embedded frontend assets do not accumulate there. On a 16 GiB Mac, set Docker Desktop to 10 GB memory and 4 GB swap before local release-image builds; release preflight rejects a macOS Docker VM below the verified memory floor. Entering Devenv installs tracked pre-commit and pre-push gates. They check the relevant changed files for whitespace, conflict markers, formatting, Svelte parse errors, Go formatting, shell syntax, and Nix syntax. They do not run tests or production builds. Run `bun run release -- check` before a release. Use `bun run verify` for a high-risk change that needs local production builds, or `bun run release -- check-full` for the complete CI-style rehearsal. `bun run build` prepares generated documentation inputs, builds the app and sites, packages the frontend for the Go embed, and builds the backend and CLI. Use a scope when you need only one part, such as `bun run build -- frontend`. Oxfmt owns frontend formatting and Oxlint owns JavaScript and TypeScript lint. ESLint remains in the frontend gate for Svelte template rules that Oxlint cannot evaluate. Turbo caches these independent checks, frontend type checks, tests, and builds by content, so an unchanged repeat restores the prior result while a relevant source or configuration change runs the full owning tool. ### Testing Source: [https://docs.openpo.st/development/testing.md](https://docs.openpo.st/development/testing.md) Run the project-owned root commands so local checks match CI. This page is for contributors choosing the smallest reliable gate for a change. ```bash bun run check bun run lint bun run test bun run build ``` `bun run verify` runs the format, check, lint, test, and build gates. The check gate also verifies local documentation links, release-version behavior, and generated OpenAPI, TypeScript, CLI, and translation artifacts. The named commands run independent work in parallel. Turbo restores unchanged type-check, frontend lint, test, policy, and build tasks by content; Go reuses its shared compile cache. Formatting and Go lint still scan their complete requested scope. Use `TURBO_FORCE=true bun run check -- frontend` only when diagnosing cache behavior, not as the normal feedback loop. Ordinary pushes use the installed changed-file syntax and formatting hook. Before a release, `bun run release -- check` runs formatting, checks, lint, and non-browser unit tests without production builds, browser suites, security scans, or Docker. `bun run release -- check-full` keeps the exhaustive local rehearsal when it is specifically needed. Targeted commands are available for faster iteration: ```bash bun run test -- backend bun run test -- frontend bun run test -- cli bun run test -- e2e bun run test -- e2e-app bun run test -- e2e-docs ``` Use the pinned Playwright Chromium installed by `setup`, and run one browser suite at a time. For visible changes, verify representative desktop and phone widths, keyboard and touch access, overflow, action visibility, and browser console health. Browser suites start their own preview servers by default so an old process on the configured port cannot satisfy the test preflight. Set `OPENPOST_APP_E2E_REUSE_SERVER=1`, `OPENPOST_MARKETING_E2E_REUSE_SERVER=1`, or `OPENPOST_DOCS_E2E_REUSE_SERVER=1` only when deliberately testing an already-running matching server.