Self-hosted social media management for Facebook, Instagram, YouTube, Telegram & Gmail -- publish & schedule posts, auto-reply to DMs, comments and emails, and run drip sequences, all from one place.
API Docs | Issues | Contributing
Tip
To run PostStack on your own Facebook & Instagram accounts you do not need to pass Meta App Review or Business Verification. Every feature — inbox, auto-reply, comment→DM, drip sequences — works under Meta's default Standard Access. App Review is only needed if you operate other people's accounts (an agency / reseller setup).
👉 Meta Access Levels — what needs App Review (and what doesn't)
- Self-hosted -- your data stays on your server, not on someone else's SaaS
- No per-contact fees -- ManyChat charges $15-65/mo and scales with your audience. PostStack is a flat self-hosted cost, free to run yourself
- No vendor lock-in -- export your data, switch providers, fork the code
- API-first operations -- publishing, inbox, CRM, rules, and automation are available via REST for custom frontends and n8n/Zapier; interactive account connection stays in the logged-in dashboard
- Extensible -- add new platforms (Telegram, TikTok) by implementing one TypeScript class
Alternative to: ManyChat, Chatfuel, ZernFlow
For: solopreneurs, agencies, and developers who want full control over their social media automation.
- Auto-reply rules -- triggers for keywords (exact, contains, starts with), comment keywords, postbacks, welcome messages, story replies/mentions, emoji reactions, and fallback/default
- Comment automation -- reply publicly under the comment, send a private DM (Meta
private_replies), or both -- scoped to a specific post or all posts, on Facebook and Instagram - AI rephrasing -- optionally rewrite any reply (including a random pool) through an OpenAI-compatible endpoint before sending
- AI-drafted replies (PRO) -- when no auto-reply rule matches an incoming comment/DM and the channel has AI-draft enabled, an LLM prepares a draft reply for human approval; plus an on-demand "Generate reply" button in the inbox. Drafts are reviewed/edited/accepted/rejected in the inbox thread (and the Approvals tab) -- never auto-sent by default; optional per-channel auto-send toggles (DM / public, default off, consent-gated). Uses the same
AI_*config (BYOK); daily budget viaAI_DRAFT_DAILY_LIMIT(default 0 = unlimited) - Live inbox -- manage all conversations, reply manually, assign to team members
- Drip sequences -- timed message series with configurable delays between steps
- Contact management -- full-text search, tag filtering, subscription management
- Tags -- organize contacts with color-coded tags
- Conversation history -- full message thread with sent/failed status tracking
- Multi-platform -- Facebook, Instagram, YouTube, Telegram, and Gmail (two-way reply/inbox channel) live today, extensible to WhatsApp, SMS, generic email and more by implementing one TypeScript provider class
- One-command startup --
docker compose up - NocoDB integration -- optional spreadsheet view of all your data (rules, contacts, messages)
- API-first operations -- scoped Bearer-key access with interactive Scalar docs
- AES-256-GCM encryption for OAuth tokens at rest
- Rate limiting on auth endpoints (Postgres-backed)
- JWT session invalidation on logout (Postgres denylist)
- HMAC-SHA256 webhook signature verification
- Per-channel webhook secrets
Production hardening notes:
- Client IP / rate limiting — the client IP is taken from the reverse proxy's
X-Real-IP(the bundled nginx sets it and strips any client-suppliedCF-Connecting-IP). SetTRUSTED_PROXY=cloudflareonly when actually behind Cloudflare. Don't expose the app directly without a proxy that overwrites these headers. - CAPTCHA — set
ALTCHA_HMAC_KEYin production. Empty = verification skipped (dev only). Solved challenges are single-use. - AI rephrase / AI-drafted replies —
AI_API_KEY(legacyOPENAI_API_KEY) sends reply text to that provider; mind GDPR (use a self-hosted/DPA endpoint viaAI_BASE_URL). AI-drafted replies (ai_draftPRO feature) reuse the sameAI_*config (BYOK); cap generations per workspace over a rolling 24h window withAI_DRAFT_DAILY_LIMIT(default0= unlimited). - Content Security Policy — uses
unsafe-inline/unsafe-evalbecause the UI runs Alpine.js + inline htmx; output is auto-escaped (hono/html), so CSP here is defence-in-depth, not the primary XSS control. - Meta
appsecret_proof— not sent by default. If you enable Require app secret in your Meta App, add it to the Graph API calls (HMAC-SHA256(page_token, META_APP_SECRET)). - Channel uniqueness — each connected account belongs to exactly one workspace: a partial unique index allows at most one active channel per
(platform, platform_id)instance-wide, so incoming events route to a single owner. Connecting an account already live in another workspace is refused. The migration that adds this index automatically disables any pre-existing cross-workspace duplicate (keeping the earliest-connected), so upgrades never fail. - First-run admin bootstrap — registration is closed by default (
REGISTRATION_ENABLEDunset/false), but the first account on an empty instance can always register, to bootstrap the owner. This means whoever reaches/registerfirst on a fresh deploy becomes the admin. Register immediately after deploying (or keep the instance network-restricted until you have), then leaveREGISTRATION_ENABLEDoff so no further self-signups are possible. - Instance operator — only operator sessions manage shared integration credentials and the instance license. Set
INSTANCE_ADMIN_EMAILSto a comma-separated list of account emails (trimmed, case-insensitive); restart to apply. When empty/unset, the earliest existing user (created_at, thenid) is the operator. A nonempty list replaces that default. Workspace API keys retain license status discovery but cannot change the license. Workspace retention remains workspace-scoped. - Single-owner workspaces (no role tiers yet) — a logged-in workspace member has full access; API keys are limited by their selected permissions. There is intentionally only one membership role (
owner); richer roles (admin/agent) and per-role authorization (requireRole()) are deferred until member invitations exist. Until then, treat any workspace member as having full access — including destructive actions (deleting channels, erasing contacts, managing API keys). - Contact identity across surfaces (Meta ASID vs PSID) — Meta gives a public comment an app-scoped user id and a direct message a page-scoped id (PSID); they are different strings with no local mapping. So the same person commenting and then DMing currently resolves to two separate contacts. Consequence: send limits/cooldowns aren't shared across the two, and an
unsubscribeor GDPR erasure applies only to the identity it was performed on. Merging them requires a Graph API lookup and is planned as a dedicated feature; until then, erase/unsubscribe both identities if a contact reached you on more than one surface. - Contact search & non-ASCII case-folding — the dashboard contact search uses
ILIKE, whose case-insensitivity for non-ASCII letters (e.g. PolishÓ/ó) depends on the database locale. The bundledpostgres:alpineinitializes with theClocale, whereILIKEonly case-folds ASCII — so searchingjózekwon't match a storedJÓZEK. For correct Polish (or any non-ASCII) case-insensitive search, initialize Postgres with a UTF-8/ICU locale (e.g. setPOSTGRES_INITDB_ARGSto use--locale-provider=icu --icu-locale=undon a fresh data volume) or add theunaccentextension. Keyword/automation matching is unaffected (it folds + NFC-normalizes in the app). The sameILIKE '%term%'search is also non-sargable (a sequential scan per keystroke); fine for small workspaces, but at scale add apg_trgmGIN index on the searched columns. - Conversation status
snoozed—snoozedis currently a manual, permanent state (likeclosed): there is no auto-reopen timer (nosnoozed_until/ scheduled un-snooze), and the inbox lists conversations of all statuses, so closed/snoozed threads still appear in the list. Treat snooze as "mark resolved-for-now" rather than "remind me later". A timed snooze + status filtering/tabs in the inbox are planned.
PostStack sends a small anonymous usage report to the maintainer once per day (and once on startup). It contains only aggregate counts (workspaces, channels, messages sent, webhooks processed, response-time aggregates…) and deployment shape (app version, runtime, OS/arch, which platforms are connected, integration on/off flags). It carries no message content, no contact data, no tokens or secrets, and no raw domain — the only identifiers are a random instance id and salted one-way hashes. Telemetry is on by default; disable it any time with POSTSTACK_TELEMETRY_DISABLED=true. Full detail: docs/PRIVACY.md.
Prerequisites: Docker, Docker Compose (for local development without Docker: Bun + Node.js 22.12+ for tooling)
Running it for real (self-host)? Skip straight to Production below — it pulls the public prebuilt images (no build, no toolchain) and the full runbook walks you through every step (HTTPS, Meta app, admin bootstrap). The Quick Start here is for trying it locally / developing.
git clone https://github.com/jurczykpawel/poststack.git
cd poststack
cp .env.example .envEdit .env -- fill in at minimum:
ENCRYPTION_KEY-- any passphrase >= 32 chars, e.g.openssl rand -base64 32JWT_SECRET-- runopenssl rand -hex 32CRON_SECRET-- runopenssl rand -hex 32META_APP_IDandMETA_APP_SECRET-- from Meta for DevelopersMETA_WEBHOOK_VERIFY_TOKEN-- any random string you chooseINSTAGRAM_APP_IDandINSTAGRAM_APP_SECRET-- optional; only needed to connect an Instagram account via Instagram Business Login (and so receive Instagram DMs at Standard Access). These are the Instagram app's id/secret (Meta app → Instagram product → "API setup with Instagram login"), different from the Facebook app'sMETA_APP_ID/META_APP_SECRET.WEBHOOK_ALLOW_PRIVATE_TARGETS-- optional, defaultfalse. Webhook delivery (channel-alert hook + outbound webhooks) is secure-by-default and blocks private/loopback/LAN targets. Settrueto allow delivery to an internal receiver (e.g. an n8n/ntfy on the same Docker network or LAN). Cloud-metadata and link-local addresses are always blocked regardless of this flag.
Builds and starts everything -- PostgreSQL, the Hono web server, graphile-worker:
docker compose --profile app upFirst run takes a few minutes (builds Docker images). Open http://localhost:3000.
Start only the databases, run the app with hot-reload:
docker compose up postgres # database only
npm install
npm run db:migrate # create tables (drizzle-kit)
npm run dev # Hono web server (Bun, terminal 1)
npm run worker # graphile-worker (Bun, terminal 2)Open http://localhost:3000.
Register an account, go to Channels, and connect your first Facebook Page or Instagram account.
Connecting Instagram — two paths. Instagram Business Login (the "+ Instagram (messaging)" button) connects one Instagram account on its own — full capabilities incl. direct messages, comments, publishing and follow-gate — and needs no Facebook page. Alternatively, a Facebook / System User connection bulk-connects all your linked Instagram accounts at once, but only for publishing and comments — Instagram DMs are not delivered that way at Standard Access; add Instagram Business Login on that account to get DMs. See docs/DEPLOY.md and docs/META_SYSTEM_USER_SETUP.md.
Dev tunnel required for Meta webhooks. Meta needs a public HTTPS URL to send events to. Run
cloudflared tunnel --url http://localhost:3000ornpx ngrok http 3000, then set the displayed URL as your webhook endpoint in the Meta App Dashboard.
If you don't want to manage .env, secrets, and a TLS proxy by hand, deploy with
StackPilot — a self-host toolbox that installs
PostStack and wires up Cloudflare/Caddy + HTTPS for you (so the TLS requirement below is handled
automatically). It generates every secret (ENCRYPTION_KEY, JWT_SECRET, CRON_SECRET,
ALTCHA_HMAC_KEY, the Postgres password), brings up the full stack (nginx + web + worker +
PostgreSQL) from the public GHCR images, and runs the database migrations:
# Fully managed — app + a Cloudflare/Caddy domain with TLS, in one command:
./local/deploy.sh poststack --domain-type=cloudflare --domain=inbox.example.com
# Or, on the server itself (installs the stack with auto-generated secrets;
# then point your own reverse proxy / Cloudflare at it for HTTPS):
curl -fsSL https://stackpilot.techskills.academy/poststack | bashThen open https://inbox.example.com/register to create the owner account and connect Meta in
Settings — no .env editing, no manual TLS. Re-running the installer preserves your existing
.env (so ENCRYPTION_KEY stays stable and stored tokens keep decrypting), and
./local/deploy.sh poststack --update pulls the latest image. Connect a Meta app and Google/YouTube
later from the dashboard.
Prefer to wire everything yourself? Use the manual flow below.
docker compose -f docker-compose.prod.yml up -dThis runs nginx (port 80) + the Hono web server + graphile-worker + PostgreSQL with public prebuilt images from GHCR — no build, no docker login, no toolchain. The web container runs database migrations on cold start before it serves. First start just pulls the images (a minute or two).
Full runbook. See docs/DEPLOY.md for the complete step-by-step guide — standing up a new instance, updating (simple and zero-blip), rollback, backups, and troubleshooting. The notes below are the essentials.
⚠️ HTTPS is required. The bundled nginx serves plain HTTP on port 80 — Meta OAuth and webhooks only work over public HTTPS, so you must terminate TLS in front of it. Easiest options:
- Cloudflare — point the (proxied/orange-cloud) DNS record at your host; Cloudflare gives free TLS at the edge and forwards to
:80. Then setTRUSTED_PROXY=cloudflarein.envso per-IP rate limiting reads the real client IP.- A TLS reverse proxy (Caddy / Traefik / nginx with certs) in front, forwarding
https://your-domain → http://host:80.Either way, set
APP_URLto the publichttps://URL — it drives the OAuth redirect URIs and the webhook callback. Don't expose the app/nginx directly on the internet without TLS + a proxy that overwrites the client-IP headers.
Images & registry. Pulls the public images
ghcr.io/jurczykpawel/poststackand…-worker— no auth needed. PinIMAGE_TAGto a released version (e.g.v0.7.1, notlatest) so upgrades and rollbacks are intentional and predictable. Forks: setIMAGE_REPOin.envto your own registry path.
Rollback. Pin the last good version and bring the stack back up — pinning
IMAGE_TAGto an explicit version (notlatest) is what makes rollbacks predictable:sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=v0.1.0/" .env # the previous good tag docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d --no-deps --wait web workerMigrations are forward-only — a rollback within a release line is safe (schema compatible); rolling back across a release that dropped/renamed columns requires restoring from a backup.
Each process opens more than one pg pool, so budget against the bundled Postgres' max_connections
(default 100) accordingly:
- web = its app pool (
DB_POOL_MAX, default 10) + a graphile-worker client pool used to enqueue jobs on webhook/connect (default ~10) → ~20 per web replica. - worker = its app pool (
DB_POOL_MAX) + the graphile-worker runner pool (≈concurrency, default 10) + a graphile-worker client pool used for in-job enqueue/drain re-dispatch (~10) → ~30 per worker replica.
So roughly web × (DB_POOL_MAX + 10) + worker × (DB_POOL_MAX + concurrency + 10). The single-host default
stack (1 web + 1 worker ≈ 50) sits well under 100, but scaling out adds ~20/web and ~30/worker — e.g. 4 web +
4 worker ≈ 200, which exceeds the default max_connections. Raise Postgres' max_connections before scaling
replicas, or lower DB_POOL_MAX. Two safety timeouts are on by default so a stalled query/transaction can't pin
a pooled connection forever: DB_STATEMENT_TIMEOUT_MS (30s) and DB_IDLE_TX_TIMEOUT_MS (60s); set either to 0 to disable.
All state lives in Postgres (the postgres_data volume) — there is no other store. Back it up regularly:
# Logical dump (portable, restore with psql/pg_restore)
docker compose -f docker-compose.prod.yml exec postgres \
pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > replystack-$(date +%F).sql.gzOr snapshot the postgres_data volume at the filesystem level while the stack is stopped. Test a restore
before you need one.
Channel OAuth tokens are encrypted at rest with ENCRYPTION_KEY. Swapping the key without
re-encrypting first makes every channel undecryptable — decryption throws, so all sends and refreshes fail
and every channel must be reconnected. To rotate safely: with the old key still in place, decrypt and
re-encrypt every channels.token_encrypted under the new key (in a maintenance step that holds both keys),
then swap ENCRYPTION_KEY to the new value and restart. Always take a backup (above) first.
New to PostStack's Meta connections, or not sure which one you (or a past-you) used? docs/META_CONNECTIONS.md maps all five connection paths — OAuth, Instagram Business Login, pasted token, and the managed/System-User connection — with what each needs, what it can do, and how its tokens expire and refresh.
DMs on both platforms in one sentence: Facebook (Messenger) DMs come with + Facebook or a User / System User token (Sources). Instagram DMs always additionally need + Instagram (messaging) (Instagram Business Login) for each IG account, with that account added as an Instagram Tester. No token, not even a permanent System User token, delivers Instagram DMs on its own at Standard Access. Measured, with step-by-step: docs/META_CONNECTIONS.md → At a glance.
Want an AI agent to click through this for you? The repo ships an agent skill,
skills/meta-app-setup, that drives the whole setup below in your own browser and stops only for your password, 2FA and consent screens.
New Meta apps are organised around use cases (not the older "products" list). Labels move around; the steps below were verified on the Meta App Dashboard in October 2026.
- Create the app at developers.facebook.com/apps → Create App:
- Use cases (filter All): Manage everything on your Page, Manage messaging & content on Instagram, Engage with customers on Messenger from Meta.
- Business: pick the business portfolio that owns your Pages (can be unverified — verification is only needed for Advanced Access).
- Meta asks for your Facebook password again on Create app.
- App settings → Basic: copy App ID / App secret into
META_APP_ID/META_APP_SECRET(.env, or Settings → Meta after first login). Set Privacy policy URL and User data deletion URL (your instance serveshttps://your-domain.com/privacy) and a Category — Meta won't let the app go Live without them. - Permissions — open each use case → Customize → Permissions and features and click Add
until the status reads Ready for testing (Meta may ask to confirm that a permission is shared
with another use case):
- Manage everything on your Page:
pages_manage_posts,pages_manage_engagement,pages_read_engagement,pages_read_user_content,business_management(Ready for testing by default), Business Asset User Profile Access. - Messenger:
pages_messaging(added by default),instagram_basic,instagram_manage_messages, Human Agent. - Instagram:
instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_messages,instagram_content_publish,instagram_manage_comments.
- Manage everything on your Page:
- Facebook webhooks — Messenger use case → Messenger API Settings → Configure webhooks:
callback
https://your-domain.com/api/webhooks/meta, verify token =META_WEBHOOK_VERIFY_TOKENof that instance (the instance must already be running with it — Meta calls the URL on Verify and save). Then subscribe the fields:messages,messaging_postbacks,message_echoes,message_reactions,message_reads,message_deliveries,feed. (PostStack also subscribes each connected Page itself; Webhooks → Subscriptions in the dashboard shows active-vs-expected.) - Facebook Login for Business → Settings → Valid OAuth Redirect URIs — add both (replace with your
real
APP_URL); without them the dashboard's Connect channel flow is rejected by Meta:https://your-domain.com/api/oauth/facebook/callbackhttps://your-domain.com/api/oauth/instagram/callback
- For Instagram DMs, also do Instagram Business Login below.
- Publish (left menu) → Publish. This switches the app to Live, which is required for real webhook events and is not App Review.
Older apps created as type Business still show the classic product list (Facebook Login for Business, Messenger, Instagram, Webhooks). The same values go into the same-named screens there.
Note: your APP_URL must be public HTTPS (see Production → HTTPS) — Meta rejects http:// and localhost for OAuth redirects and webhooks. For local testing, expose your dev server with a tunnel (cloudflared tunnel --url http://localhost:3000 or npx ngrok http 3000) and use that HTTPS URL. Some permissions require Meta App Review for production; in development mode you can test with your own accounts without review.
A Facebook-Login connection (the + Instagram button) covers Instagram publishing and
comments — it requests instagram_content_publish in addition to the messaging/comment scopes
below. Instagram DMs, though, are only delivered at Standard Access through Instagram
Business Login — a second, separate login flow with its own app ID and secret, living inside
the same Meta app. A channel connected before publishing/comment scopes were added must be
reconnected once (Channels → Reconnect) to pick them up — see
docs/META_CONNECTIONS.md for the full map of connection paths.
- In your Meta app open the Instagram use case (older apps: the Instagram product) → API setup with Instagram login (not "API setup with Facebook login"). This screen shows an Instagram app ID and Instagram app secret (behind Show) — they are different from the Facebook app's ID/secret.
- Set them as
INSTAGRAM_APP_ID/INSTAGRAM_APP_SECRET(.envor Settings → Meta). The secret is also used to verify the signature of Instagram webhook deliveries, so rotate it in both places. The + Instagram (messaging) button appears on the Channels page only once both are set. - Configure webhooks (same screen): the same callback URL and verify token as the Facebook
webhooks (
https://your-domain.com/api/webhooks/meta, value ofMETA_WEBHOOK_VERIFY_TOKEN), then make sure these fields are Subscribed:messages,messaging_postbacks,message_reactions,messaging_seen,comments,live_comments. - Set up Instagram business login → Set up (same screen): enter the redirect URL
https://your-domain.com/api/oauth/instagram-login/callbackand Save. - Give the Instagram account a role — required even when the app is Live. At Standard Access the API only serves accounts with a role on the app: App roles → Roles → Add People → Instagram Tester → the account's username, then accept the invite from that Instagram account (instagram.com → Settings → Apps and websites → Tester Invites → Accept). Without it the login screen still works, but the token exchange fails afterwards (see Troubleshooting).
- The Instagram account must be Business or Creator and allow message access for connected tools.
- Switch the app to Live — Development mode delivers no real webhook events (see Meta Access Levels).
- On the Channels page click + Instagram (messaging), make sure Instagram shows the right account on the consent screen ("…is requesting access to: your account") and Allow.
PostStack requests instagram_business_basic, instagram_business_manage_messages,
instagram_business_manage_comments and instagram_business_content_publish on this path and
subscribes each connected account to messaging webhooks automatically. The dashboard's channel page
has the same guide in-app (Connecting Instagram — what works, and when).
| Symptom | Cause / fix |
|---|---|
| "The callback URL or verify token couldn't be validated" | The URL must be exactly …/api/webhooks/meta (not /webhook), publicly reachable over HTTPS, and the verify token must equal META_WEBHOOK_VERIFY_TOKEN of the instance behind that URL. Rejections are logged by PostStack with the reason. |
| "Insufficient Developer Role" when generating an Instagram token | The Instagram account has no accepted Instagram Tester role on this app (step 5). A Facebook-side admin/developer role is not enough. |
+ Instagram (messaging) ends with "Connection failed", and the log shows Instagram-Login long-lived exchange failed: … "Unsupported request - method type: get" |
Same cause: the Instagram account has no accepted Instagram Tester role (step 5). Meta issues a token, but every API call with it is rejected — also when the app is Live. Add the role, accept the invite on Instagram, connect again. |
| "Invalid Scopes: publish_video" on the Facebook login screen | Older versions of PostStack requested this live-video scope, which apps created with use cases cannot add. Update PostStack and retry connecting the Page. |
/channels?error=no_pages although you picked a Page |
Older versions of PostStack did not request business_management, so /me/accounts could omit Pages owned by a business portfolio. Update PostStack, verify this permission in step 3, and reconnect, selecting the business portfolio and Page in the consent dialog. |
| "That channel needs a PRO license" after connecting | The FREE tier allows one channel per Meta platform; connecting a second Facebook Page or Instagram account needs PRO. Remove the old channel or add a license. |
/channels?error=oauth_failed right after the consent screen |
Check the instance log for the line starting with [oauth/ — it carries Meta's exact error. A redirect URI missing from the app gives a Meta error page before the consent screen instead. |
Facebook Page token has instagram_manage_messages, but IG DMs never arrive |
Expected at Standard Access — connect the account via Instagram Business Login (a channel with the Facebook only badge has no DM webhooks). |
| Webhook "Test" works, real messages don't | The app is in Development mode — switch it to Live. A test app (a "test version" of another app) is always in Development mode and cannot go Live; use a regular app. |
| Webhook deliveries rejected with a signature error | INSTAGRAM_APP_SECRET (IG events) or META_APP_SECRET (FB events) doesn't match the secret currently shown in the Meta dashboard — e.g. after a reset. |
One-token setup (recommended for self-host): instead of connecting Pages one by one, paste a single permanent Meta System User token and PostStack auto-connects every Page + linked Instagram account it can reach, and keeps them in sync. Full guide: docs/META_SYSTEM_USER_SETUP.md.
The scheduled Meta API Version Check is deliberately detection-only: when Meta releases a
new Graph API version, it opens an issue but never creates a version-bump PR by itself. A PR can
only be created by manually dispatching that workflow with confirm_live_probe=true; the run must
pass contract/unit tests and a strict live probe against dedicated test assets.
Configure these repository Actions secrets for a non-production Meta App, Page, Instagram account, and test recipient — never reuse customer or production credentials:
| Secret | Value |
|---|---|
META_PROBE_APP_ID / META_PROBE_APP_SECRET |
Facebook app credentials for the dedicated test app. |
META_PROBE_PAGE_TOKEN / META_PROBE_PAGE_ID |
Page token and numeric ID for the dedicated test Page. |
META_PROBE_IG_ID |
Instagram business-account ID linked to the test Page. |
META_PROBE_IG_TOKEN |
Long-lived token generated by the test app's separate Instagram Business Login setup. |
META_PROBE_IG_USER_ID |
Optional. IGSID of a dedicated test recipient with an open messaging window. Without it the real IG DM send is skipped and must be checked manually (see below). |
META_PROBE_PSID |
Test recipient PSID with an open 24-hour messaging window. |
The strict run publishes and deletes one throwaway Page post, adds a throwaway first comment, and
sends one Facebook test DM (plus one Instagram test DM when META_PROBE_IG_USER_ID is set).
It also sends the production Instagram DM payload to a recipient that cannot exist. The expected
"recipient not found" rejection proves the endpoint, version and payload keys still work, and is
reported as PARTIAL: Meta checks the recipient before required fields, so a newly required field,
a changed success response or delivery problems are not covered. That is why the generated PR
carries a mandatory checklist item — send one real Instagram DM from TEST before merging. The PR also
lists the changelog sections for the new version that mention surfaces PostStack calls (a keyword
scan to speed up review, not a compatibility guarantee). Before dispatching it, open fresh conversations
from dedicated test accounts to the Page (and the Instagram account, if you set META_PROBE_IG_USER_ID) so the replies are policy-compliant.
The Facebook Page token is used only on graph.facebook.com; the Instagram Business Login token is
used only on graph.instagram.com. Rotate expiring test tokens before a strict run. A green run still
requires a maintainer to review Meta's changelog and execute the assisted inbound-webhook check before
merging its PR.
Short answer: if you self-host and only operate your own Facebook Pages / Instagram accounts, you do not need Meta App Review or Business Verification. Every PostStack feature works under Standard Access, which every app has by default. App Review only becomes relevant when you start operating accounts that belong to other people (a multi-client / agency setup).
Meta gates every permission behind one of two tiers:
| Tier | What it grants | Requires |
|---|---|---|
| Standard Access (default, every app) | The permission works for any user who has a role on your app (Admin / Developer / Tester) and for the Pages / IG accounts those users manage. | Nothing — no review. |
| Advanced Access | The same permission, but usable with users who do NOT have a role on your app — i.e. the general public / other people's accounts. | App Review + Business Verification. |
When you create your own Meta app you are automatically its Admin, and the Pages / IG accounts you connect are ones you manage. So every permission PostStack requests resolves at Standard Access — no review needed. That is why all the inbox / auto-reply / comment features work on your own accounts without App Review.
One caveat about app mode (distinct from review): to receive real webhook events (incoming DMs, comments) — even for your own Pages — the Meta app must be switched to Live mode. In Development mode Meta delivers only test webhooks fired from the App Dashboard, and no production data is delivered, not even for admins/developers/testers. Switching to Live is a one-time toggle (it just needs a Privacy Policy URL set on the app) and is not the same as App Review — Live mode ≠ Advanced Access. So for your own accounts: flip the app to Live, but you still do not need App Review.
The line you cross: the moment you want to handle a Page or IG account that someone else administers (a client, a customer) and that person is not added as a Tester on your app, that account is "a user without a role on your app" → you need Advanced Access for the relevant permissions, which means App Review + Business Verification. This is exactly what a managed / reseller offering (the "connect under one verified app" model) requires.
PostStack requests only these scopes — Facebook: pages_show_list, business_management, pages_messaging,
pages_read_engagement, pages_manage_metadata, pages_manage_posts, pages_manage_engagement;
Instagram additionally: instagram_basic, instagram_manage_messages,
instagram_manage_comments, instagram_content_publish.
| Feature | Permission(s) | Your own accounts (Standard Access, app in Live mode) | Other people's accounts |
|---|---|---|---|
| List & connect your Pages / IG | pages_show_list |
✅ works | needs Advanced Access |
| List Pages owned by a business portfolio | business_management |
✅ works | needs Advanced Access |
| Receive DMs in the inbox (FB Messenger) | pages_messaging |
✅ works | needs Advanced Access |
| Receive DMs in the inbox (Instagram) | instagram_basic, instagram_manage_messages |
✅ works | needs Advanced Access |
| Send replies / auto-replies (FB + IG DM) | pages_messaging / instagram_manage_messages |
✅ works | needs Advanced Access |
| Read post comments | pages_read_engagement (FB) / instagram_manage_comments (IG) |
✅ works | needs Advanced Access |
| Comment → DM (private reply) | pages_messaging (FB) / instagram_manage_messages (IG) |
✅ works | needs Advanced Access |
| Publish Page posts, photos & videos | pages_manage_posts |
✅ works | needs Advanced Access |
| Publish to Instagram | instagram_content_publish |
✅ works | needs Advanced Access |
| Post the first comment / reply to comments as the Page | pages_manage_engagement |
✅ works | needs Advanced Access |
| Manage Page webhook subscriptions | pages_manage_metadata |
✅ works | needs Advanced Access |
| Read receipts, echoes, delivery receipts in the thread | pages_messaging |
✅ works | needs Advanced Access |
Emoji reactions on your DMs (the message_reactions webhook) |
pages_messaging |
needs Advanced Access |
So the only own-account limitation is that the emoji-reaction webhook (message_reactions)
won't be delivered until the app has Advanced Access on pages_messaging. Everything else — inbox,
auto-replies, comment automation, sequences, read/delivery receipts — works fully on your own
accounts with no review.
- Token longevity is independent of review — see the System User guidance below / managed connection docs. Review does not make tokens last longer.
- Messaging policy windows (e.g. Meta's standard messaging window after a user's last message) apply regardless of access tier.
- Switching the app to Live mode without Advanced Access does not grant access to other people's accounts — only App Review does.
- Self-hosting for your own brand's Pages/IG → No App Review. (But do switch the app to
Live mode so production webhooks are delivered — that's a toggle, not a review. Optional:
request Advanced Access on
pages_messagingonly if you want emoji-reaction webhooks.) - Managing a client's account where you add them as a Tester on your app and they accept → No (they now have a role on your app).
- Offering this as a service to customers who connect their own accounts under your app → Yes — App Review + Business Verification for the permissions above.
This isn't unique to Meta. None of PostStack's currently-supported channels require a lengthy review or identity/business verification to run on your own accounts — only the normal, self-service developer-app configuration:
- Telegram — literally just a bot token from @BotFather. No app, no review, nothing else.
- Facebook / Instagram — your own Pages/IG under Standard Access (above); just create the app and switch it to Live.
- Gmail / YouTube (Google) — your own (or test-user) account works without verification; set the OAuth consent screen to In production so refresh tokens are long-lived. Google's full verification + CASA review is only needed to serve external mailboxes/channels at scale (beyond ~100 users / sensitive scopes) — i.e. the same "other people's accounts" line as Meta.
In short: deploy, configure your own developer app, connect your accounts — no weeks-long approval gate.
To enable Gmail mailbox support, create a Google Cloud OAuth app:
- Go to Google Cloud Console and create a new project.
- Enable the Gmail API in your project (APIs & Services → Library → search "Gmail").
- Create an OAuth 2.0 Client ID (APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID):
- Application type: Web application
- Authorized redirect URIs: add
https://your-domain.com/api/oauth/gmail/callback(use your realAPP_URL)
- Copy the Client ID and Client Secret into
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETin your.env(or set them in Settings → OAuth after first login). - OAuth scope: PostStack requests
openid,email,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.send— read-only ingest and send.
Verification note: Google restricts these scopes. An unverified app can only authorize the developer's own Gmail account + ~100 test users. To serve external mailboxes (real multi-tenant usage), your Google app must pass OAuth verification and Google's CASA security assessment (annual; separate fee). For self-hosting your own team's Gmail, add your account as a test user — no assessment needed.
Publishing status matters (the Google equivalent of Meta's Live mode): while the OAuth consent screen is left in Testing, Google expires refresh tokens after 7 days — so the channel silently stops working a week after you connect it. Set the consent screen's publishing status to In production (Production) to get long-lived refresh tokens. Production without full verification still works for your own/test-user accounts (with the unverified-app warning, capped at ~100 users); verification + CASA is only needed to lift that cap for external mailboxes.
Optional: each Gmail channel can use an ingest filter (e.g. label:support, is:unread, -category:promotions) to narrow the messages PostStack polls. Set it in the dashboard or via the API when connecting the channel.
The same Google app can also serve YouTube (youtube platform) — just add the YouTube channel to the app and use the same GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET.
An instance operator with a Business license can set the product name, logo, favicon, accent,
default theme, contact email, public HTTPS origin and footer in Settings → Integrations → Instance
branding. This config belongs to the entire installation, independently of content brands. It is
stored in instance_settings; the optional INSTANCE_BRANDING environment variable accepts the same
JSON object (name, logoUrl, faviconUrl, accent, theme, contactEmail, publicUrl, footer).
Resetting clears the stored override and falls back to the environment or defaults.
An empty favicon generates an icon from the accent. Without white_label entitlement the default
identity is used, including when a license expires. BRAND_NAME alone no longer changes the identity.
Changes apply on the next request, with up to 30 seconds for other web/worker processes to refresh.
The public homepage uses an instance page without built-in marketing images; Privacy and Terms reuse
the built legal content in the instance shell at serving time, so no image rebuild is needed. The
white-label public pages load no analytics. Crawler documents (robots.txt, sitemaps and llms.txt)
also use the configured identity and origin. Set APP_URL and provider callback registrations to the
actual installation origin as usual; the branding origin supplies public links and does not change
OAuth destinations or license-domain binding. Cookie names, API prefixes and signed webhook header
names remain stable. Operational email webhook templates can use {{brand}}, {{contact_email}},
{{public_url}} and {{footer}}; outbound webhook User-Agent uses the instance identity.
Public Privacy Policy and
Terms of Service are served by the same app at
/privacy and /terms, with visible footer links. The policy covers TikTok account data,
publishing and deletion; self-hosted operators remain responsible for their instance data.
TikTok uses the inbox / draft flow by default. The video lands in your TikTok inbox; open the
TikTok app to choose its caption, cover and privacy and confirm publication. Direct Post requires
video.publish and an app that meets TikTok's audit requirements. Unaudited clients remain restricted
to private (SELF_ONLY) visibility; PostStack never substitutes private visibility for your choice.
Compose loads the selected brand's creator info and shows the TikTok nickname, a playable preview
and an editable caption. Choose Direct Post to select privacy (no default), allowed interactions
and commercial-content disclosure. Creator-disabled interactions stay disabled; branded content
cannot be private. Inbox remains the default delivery mode.
Publishing and scheduling require explicit confirmation of this submission, with links to TikTok's
Music Usage Confirmation and, for branded content, Branded Content Policy. Scheduling shows the
exact local date, time and timezone for automatic publication. Changes to the account, media,
caption, settings or schedule clear confirmation. The app records the confirmed channel, settings
and submission time in the workspace audit log. Processing may take a few minutes; delivery status
is available in the content cockpit and Queue. After TikTok accepts the upload, the delivery stays
processing while PostStack polls until TikTok reports publication or a final failure (including
inbox submissions awaiting completion in TikTok). A failed status read retries without uploading again.
No watermark or logo is added to the video.
At send time, Direct Post queries fresh creator info. If privacy, interactions, video duration or
posting eligibility changed, the delivery is held with a reason and a post.held event plus the
configured delivery alert. Open the delivery in Queue → Review TikTok settings, select current
choices (privacy has no default), confirm consent and choose Publish now. Reconnecting a channel
or changing a date does not release a delivery awaiting confirmation.
Operators can enable TikTok: zawsze potwierdzaj przy wysyłce in Settings → integration credentials,
or set TIKTOK_CONFIRM_AT_SEND=true. The default is off: valid scheduled submissions publish
automatically. When on, every scheduled TikTok delivery waits for that confirmation. The setting
also applies to saved schedules; confirmation authorizes one immediate delivery only.
The post API supports the settings below for integrations providing their own creator-facing posting and consent flow.
Create or patch an editorial post with these nullable fields:
| Field | Meaning |
|---|---|
tiktokPublishMode |
inbox (default when absent) or direct |
tiktokPrivacyLevel |
Explicit creator choice from privacy_level_options; required for Direct Post, with no default |
tiktokDisableComment, tiktokDisableDuet, tiktokDisableStitch |
false allows the interaction; absent/null disables it. Creator restrictions always force disable. |
tiktokBrandContentToggle |
Paid partnership; defaults off and cannot use SELF_ONLY |
tiktokBrandOrganicToggle |
Promotion of the creator's own business; defaults off |
tiktokCoverTimestampMs |
Optional video frame for the cover, in milliseconds |
These choices are captured in the delivery when publishing or scheduling the post. The caption
becomes TikTok's post_info.title; the existing aiDisclosure setting supplies is_aigc.
Direct Post checks the registered video duration against the smaller of the current creator limit
and the 600-second catalog ceiling. An unavailable duration or creator limit stops publication.
New URL uploads are probed with ffprobe (included in Docker; install FFmpeg for local development).
For older media with no duration, register it again with duration metadata through the media API.
Set TIKTOK_PULL_URL_PREFIX=https://media.example.com/videos/ only after verifying that domain or
URL prefix in the TikTok developer portal. Matching HTTPS media URLs use PULL_FROM_URL unchanged;
other URLs use FILE_UPLOAD, with the original bytes unchanged (single chunk, up to 64 MiB).
No prefix means FILE_UPLOAD. A per-request ingestion option cannot override this configuration.
Neither path adds a watermark or logo.
Creator profile and Direct Post permissions are available at
GET /api/v1/channels/{channelId}/tiktok/creator-info (channels:read scope) and the
session-only dashboard equivalent /channels/{channelId}/tiktok/creator-info. The response contains
data.status: "available" with creator_info (nickname, privacy options, interaction settings and,
when supplied by TikTok, the video duration limit), or data.status: "cannot_post_now" with a reason
and a try-again-later message. TikTok must grant video.publish for this query. Responses are cached
in process for 60 seconds per channel and credentials; ?refresh=true forces a new TikTok query.
Server callers use getTikTokCreatorInfo(workspaceId, channelId, { refresh: true }) for fresh
publish-time permissions. Direct publishing also queries TikTok afresh before initializing each post
and stops when the creator cannot post now. The inbox/draft flow is unchanged.
The other publishing channels — X, LinkedIn, Threads, YouTube, Facebook & Instagram — publish directly, subject to each platform's own developer-app setup and API access tier.
Go to Channels and click + Facebook or + Instagram. You'll be redirected to Meta to authorize access. After granting permissions, your Pages/IG accounts appear in the list.
Go to Rules and click + New Rule:
- Name -- e.g. "Welcome keyword"
- Trigger -- choose "Keyword (DM)", enter keywords like
hello, hi, start - Match type -- "Contains", "Exact", or "Starts with"
- Reply text -- the message to send back automatically
- Priority -- higher = checked first (rules are evaluated top to bottom)
- Cooldown -- minimum seconds between fires for the same contact
For comment automation, use "Keyword (Comment)" trigger -- when someone comments a keyword on your post, PostStack sends them a DM.
Go to Inbox to see all incoming conversations. Click a conversation to view the message thread. Type a reply and press Enter to send manually.
You can:
- Mark as read -- click a conversation to reset unread count
- Close -- mark conversation as resolved
- Pause automation -- stop auto-replies for a specific conversation
Go to Sequences and click + New Sequence. Add steps:
- Message -- text to send
- Delay -- wait N minutes before the next step
Activate the sequence, then enroll contacts via the API:
The API key needs sequences:write.
curl -X POST https://your-domain.com/api/v1/sequences/{id}/enroll \
-H "Authorization: Bearer sk_live_your-key" \
-H "Content-Type: application/json" \
-d '{"contact_id": "...", "channel_id": "..."}'Go to Settings and create an API key. Use it with any HTTP client:
Choose contacts:read for listing contacts and conversations:write for sending replies.
# List contacts
curl https://your-domain.com/api/v1/contacts \
-H "Authorization: Bearer sk_live_your-key"
# Send a manual reply
curl -X POST https://your-domain.com/api/v1/conversations/{id}/messages \
-H "Authorization: Bearer sk_live_your-key" \
-H "Content-Type: application/json" \
-d '{"text": "Thanks for reaching out!"}'Interactive docs at /api/docs (Scalar UI).
replystack/
├── src/
│ ├── server/ # Hono app (web process)
│ │ ├── app.ts # factory: security headers, CORS, route mounting
│ │ ├── index.ts # entrypoint
│ │ ├── routes/ # public / v1 / oauth / webhooks / pages routers
│ │ ├── handlers/ # framework-neutral HTTP handlers (routes delegate here)
│ │ ├── ui/ # hono/html SSR templates + CSS (htmx + Alpine)
│ │ └── middleware/ # security headers, page auth
│ ├── lib/
│ │ ├── platforms/ # SocialProvider base + Facebook, Instagram
│ │ ├── rules/ # matcher + executor (keyword, comment, story, reaction, ...)
│ │ ├── workers/ # graphile-worker job processors
│ │ ├── queue/ # graphile-worker client (addJob + task list)
│ │ ├── auth/ # JWT sessions, API keys, password hashing
│ │ ├── api/ # response helpers, rate limiter, OpenAPI spec
│ │ ├── oauth/ # OAuth flow helpers
│ │ ├── channels/ # channel connect / upsert
│ │ ├── notifications/ # channel-failure alerts (outbound webhook)
│ │ ├── ai/ # AI rephrase adapter (optional)
│ │ └── crypto.ts # token encryption (AES-256-GCM)
│ ├── db/ # Drizzle schema + relations
│ └── types/ # shared types
├── worker/ # standalone graphile-worker process (inbox-worker.ts)
├── drizzle/ # generated SQL migrations (drizzle-kit)
└── docker/ # Dockerfile, Dockerfile.worker, nginx.conf
Web (Hono on Bun) Worker (graphile-worker on Bun)
───────────────── ───────────────────────────────
POST /api/webhooks/meta ──> incoming-message ──> contact upsert
──> rule engine ──> outgoing-message
GET/POST /api/v1/* incoming-comment ──> comment log + rule eval
GET /api/oauth/* (dashboard session only) ──> outgoing-comment / outgoing-private-reply
GET /api/cron/token-refresh incoming-reaction ──> reaction rule eval
outgoing-* ──> Meta Graph API send
token-refresh ──> refresh expiring OAuth tokens
sequence-steps ──> drip campaign delivery
PostStack exposes its operational data and automation through REST. Interactive channel connection and reconnection remain logged-in dashboard actions because they involve browser redirects and third-party consent.
Docs: GET /api/docs -- interactive Scalar UI
Auth: Authorization: Bearer sk_live_<key> (generate in Settings > API Keys)
API-key administration and /api/oauth/* connection flows require a logged-in dashboard session;
API keys authenticate operational /api/v1/* integrations only.
Choose only the permissions an integration needs. Publishing uses separate content:*, posts:*,
brands:*, and media:write permissions; every API-key-authenticated operation exposes its exact
requirement as x-required-scope in the OpenAPI document.
| Endpoint | Methods | Description |
|---|---|---|
/api/v1/channels |
GET | List connected channels |
/api/v1/channels/:id |
GET, PATCH, DELETE | Channel detail, rename, disconnect |
/api/v1/conversations |
GET | List conversations (cursor-paginated) |
/api/v1/conversations/:id |
GET, PATCH | Detail, mark read, close, pause automation |
/api/v1/conversations/:id/messages |
GET, POST | Message history + send reply |
/api/v1/rules |
GET, POST | List and create auto-reply rules |
/api/v1/rules/:id |
GET, PATCH, DELETE | Rule detail, update, delete |
/api/v1/sequences |
GET, POST | List and create drip sequences |
/api/v1/sequences/:id |
GET, PATCH, DELETE | Sequence detail, update status, delete |
/api/v1/sequences/:id/enroll |
POST | Enroll a contact in a sequence |
/api/v1/contacts |
GET | List contacts (search, tag filter, cursor-paginated) |
/api/v1/contacts/:id |
GET, PATCH | Contact detail, update name/email |
/api/v1/tags |
GET, POST | List and create tags |
/api/v1/content |
GET, POST | List and create editorial content |
/api/v1/content/:id |
GET, PATCH, DELETE | Content detail, update, delete |
/api/v1/posts |
GET, POST | List and create platform posts |
/api/v1/posts/:id |
GET, PATCH, DELETE | Post detail, update, delete |
/api/v1/posts/:id/publish |
POST | Publish now or schedule |
/api/v1/brands |
GET, POST | List and create publishing brands |
/api/v1/brands/:key |
PATCH, DELETE | Update and delete a brand |
/api/v1/brands/:key/channels |
GET | Resolve a brand's publishing channels |
/api/v1/media |
POST | Register media for publishing |
/api/v1/api-keys |
GET, POST | List and create API keys |
/api/v1/api-keys/:id |
DELETE | Revoke an API key |
curl https://your-domain.com/api/v1/contacts \
-H "Authorization: Bearer sk_live_your-key-here"All responses follow the shape { data, error, meta? }.
Coming from Buffer, Hootsuite, Later, Publer, SocialBee, or ManyChat? See docs/migration for step-by-step guides and a reference import script — bring your scheduled posts, subscribers, and tags into your own instance, no per-contact fees.
| Layer | Technology |
|---|---|
| Framework | Hono (web server + API) |
| UI | Server-rendered HTML + htmx + Alpine.js (no client framework) |
| Database | PostgreSQL + Drizzle ORM |
| Queue | PostgreSQL (graphile-worker) |
| Auth | JWT (jose) + API keys |
| Encryption | AES-256-GCM (tokens at rest) |
| Runtime | Bun |
| Infra | Docker Compose |
NocoDB connects to the same PostgreSQL database and gives you a spreadsheet view of all your data -- useful for editing rules in bulk, browsing contacts, or monitoring sequences.
docker compose --profile nocodb upFor security, create a read-only PostgreSQL role for NocoDB:
CREATE ROLE nocodb_readonly LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE replystack TO nocodb_readonly;
GRANT USAGE ON SCHEMA public TO nocodb_readonly;
GRANT SELECT ON contacts, contact_tags, tags, conversations, messages,
auto_reply_rules, sequences, sequence_enrollments,
comment_logs TO nocodb_readonly;Then update NC_DB in docker-compose.yml to use this role.
See Quick Start - Option B for setup. Additional commands:
npm run lint # ESLint
npm run typecheck # TypeScript (tsc --noEmit; Bun runs the TS entrypoint directly, no build artifact)
npm test # Vitest unit
npm run test:integration # Vitest integration (needs a Postgres; set TEST_DATABASE_URL)
npx playwright test --config=playwright.tiktok.config.ts --project=chromium # Offline TikTok Compose (dedicated E2E_DATABASE_URL)- Project skeleton, auth, Docker Compose
- OAuth channels (Facebook + Instagram)
- Meta webhook receiver (HMAC verified)
- Inbox -- conversation list, message thread, manual reply
- graphile-worker job processors (incoming/outgoing messages, comments, reactions, token refresh, sequences)
- Rule engine -- keyword auto-reply with cooldown
- Comment automation -- public reply + private DM (first-touch), Facebook & Instagram
- Story reply/mention and emoji reaction triggers
- Optional AI rephrasing of replies (OpenAI-compatible)
- AI-drafted replies (PRO) -- auto on no-rule-match + on-demand, human-approved from the inbox (BYOK)
- Drip sequences with delays
- Contacts CRM with tags and search
- API key management + token refresh
- Rate limiting, JWT invalidation, outgoing message idempotency (all Postgres-backed, no Redis)
- CI/CD (lint, typecheck, test, Docker build + push)
- Gmail support (Google OAuth2, Gmail API, cron polling, per-mailbox ingest filter)
- Telegram support
- Discord support (Bot API, Gateway WebSocket)
- Visual flow builder (v2)
- Broadcast campaigns
- Webhook events (outbound notifications)
- Multi-workspace team management
Contributions are welcome. See CONTRIBUTING.md for guidelines.
- Create
src/lib/platforms/{platform}.tsextendingSocialProvider - Register it in
src/lib/platforms/registry.ts - Add OAuth start and callback routes at
src/server/handlers/oauth/{platform}/; both must use the shared dashboard-session boundary - Add the platform to the
platformenum insrc/db/schema.ts
git checkout -b feat/your-feature
# make changes
npm run typecheck && npm test
git commit -m "feat: your feature"PostStack's core is free and self-hosted. If it replaced a scheduler subscription for you — Meta changes its API at 2 a.m. and pizza helps keeping up:
Elastic License 2.0 — source-available.
You're free to self-host, use, modify, and redistribute PostStack. The only limits: you may not offer it to third parties as a hosted/managed service, and you may not circumvent the license-key functionality. Self-hosting it for your own business is always free. For a commercial/managed-service license, get in touch.
Built with inspiration from: