Social publishing setup

Everything that has to happen in a developer portal before Facebook Pages, Instagram, TikTok and YouTube publishing works for a real business — and how to verify each one.

The owner-facing half of the Social Poster. The code (Settings → Integrations connect cards, the /social-poster page, the social-publish queue) is shipped; this document lists every step that has to happen in a third-party developer portal or in Railway before a real business can publish, and how to verify each platform once it is wired. A Worker cannot do any of these steps — they need the owner's portal logins.

How the feature works internally is documented in the Memory bundle (.fredrin/memory/concepts/social-publishing.md). Everything below was read from the shipped code on the staging/social-poster branch; platform-side facts that could not be checked against official documentation during the build are marked (verify) rather than asserted.


0. Quick reference — what the code expects

PlatformEnv vars (apps/api/src/config/env.ts)Connect (org admin only)Callback = redirect URI to registerConsent scopes requested
Facebook PagesMETA_APP_ID, META_APP_SECRET, META_REDIRECT_URI, META_API_VERSIONGET /organizations/:orgId/businesses/:businessId/social/meta/connectGET /integrations/meta/callback (shared with the ads dialog)pages_show_list,pages_read_engagement,pages_manage_posts,instagram_basic,instagram_content_publish + pages_manage_engagement,instagram_manage_comments when META_COMMENTS_SCOPE_ENABLED is on
Instagramsame as Facebook (an Instagram destination publishes through its linked Page)same dialog; picked via GET …/social/meta/pages + POST …/social/meta/selectsamesame
Instagram Login (no Page)INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, INSTAGRAM_REDIRECT_URI (+ META_API_VERSION)GET /organizations/:orgId/businesses/:businessId/social/instagram/connectGET /integrations/instagram/callbackinstagram_business_basic,instagram_business_content_publish + instagram_business_manage_comments when META_COMMENTS_SCOPE_ENABLED is on
TikTokTIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET, TIKTOK_REDIRECT_URIGET /organizations/:orgId/businesses/:businessId/social/tiktok/connectGET /integrations/tiktok/callbackuser.info.basic,video.publish,video.upload
YouTubeGOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_YOUTUBE_REDIRECT_URIGET /organizations/:orgId/businesses/:businessId/social/youtube/connectGET /integrations/youtube/callbackhttps://www.googleapis.com/auth/youtube.upload, https://www.googleapis.com/auth/youtube.readonly, openid, email

Shared by every platform:

  • Media source. Posts reference the business's asset library (assets table); uploads are capped at 200 MB per file (apps/api/src/modules/assets).
  • Public API URL. API_PUBLIC_URL is the base every asset file URL is minted against. Only the Instagram publisher hands that URL to a third party (Meta fetches the media itself); Facebook, TikTok and YouTube push bytes from the API, so they work from localhost.
  • Landing page. Every callback redirects to ${WEB_URL}/settings/integrations?social=<result> — meta_connected, instagram_connected, tiktok_connected, youtube_connected, no_channel, denied, meta_error, instagram_error, tiktok_error, youtube_error. WEB_URL must therefore be the real web origin in every environment.
  • Not configured = 503, not a crash. Every platform's env vars are optional so the API boots without them; the connect card shows a "not configured" state and the connect endpoint answers 503 until they are set.
  • Queue. Publishing runs on the social-publish BullMQ queue inside the API process; a scheduled post is picked up by a per-minute sweeper. Both are visible at /admin/queues (gated by PLATFORM_ADMIN_EMAILS).

1. Meta — Facebook Pages + Instagram

Where things stand

  • App KeyesCode Marketing OS, app ID 798893679822212. App Review approved (2026-08-26) Advanced Access for the six read/ads permissions the ads and Content features use: ads_read, ads_management, business_management, pages_show_list, pages_read_engagement, pages_read_user_content, plus the Marketing API Access Tier and public_profile.

  • The app is Live/Published (switched 2026-08-26) and the business is verified as a Tech Provider (2026-07-27).

  • The three publishing permissions the Social Poster adds — pages_manage_posts, instagram_basic, instagram_content_publish — were submitted for App Review on 2026-09-17 (submission 880842051627374). Verdict, recorded 2026-09-26 and read back from the portal on 2026-09-27: instagram_basic was approved; pages_manage_posts and instagram_content_publish were rejected because the screencast did not show the whole flow. Meta accepted the use case itself. The eight existing approvals were renewed. The reviewer's text is in META_APP_REVIEW.md → §5.0.

  • Ten permissions production already asks for are not approved. They sit at Standard Access ("Ready for testing", or "App Review rejected" for the two above), which means only a user with an app role (admin, developer or tester) can grant them. That is enough for the owner's own Page and Instagram account, but for every other customer the feature behind each one fails:

    FeaturePermissions waiting
    Publishing through Facebook Loginpages_manage_posts, instagram_content_publish
    Publishing through Connect with Instagraminstagram_business_basic, instagram_business_content_publish
    Engagement inbox (comments and replies)pages_manage_engagement, instagram_manage_comments, instagram_business_manage_comments
    Lead ads (ads connect dialog)leads_retrieval, pages_manage_ads, pages_manage_metadata

    All ten went out in one resubmission with three screencasts on 2026-09-27 (request 895569756821270, "Review in progress", up to 20 days). The package is in META_APP_REVIEW.md → §5.6 (MARK-O4F4FX).

  • read_insights, instagram_manage_insights, instagram_business_manage_insights and catalog_management are not requested: no dialog asks for them yet (§5.6.2).

What the code does (nothing to change in the portal for this)

  • The connect card (its button reads Connect with Facebook, or Reconnect Facebook once an account is connected) opens the classic OAuth dialog built by MetaClientService.buildPublishOAuthUrl with the scope string pages_show_list,pages_read_engagement,pages_manage_posts,instagram_basic,instagram_content_publish and a signed state whose purpose is meta_publish. When META_COMMENTS_SCOPE_ENABLED is on — it is in production, since 2026-09-25 — the string also carries pages_manage_engagement,instagram_manage_comments for the Engagement inbox. Both must be added to the app in the portal before the flag is switched on, or the dialog fails for everyone with "Invalid Scopes".
  • The redirect URI is the existing META_REDIRECT_URI (https://marketing-api.keyescode.com/integrations/meta/callback in production, already registered as a Valid OAuth Redirect URI). The same callback serves the ads dialog and the publishing dialog; it branches on the state purpose. No new redirect URI is needed in the Meta dashboard.
  • Meta grants are cumulative per user + app, so completing the publishing dialog overwrites the business's meta_connections token with one that carries the six read/ads scopes and the publishing scopes. The ads and Content features keep working.
  • After the round trip the card lists the user's Pages from /me/accounts (with each Page's linked instagram_business_account, if any) and the admin ticks which Pages / Instagram accounts to enable. Each pick becomes a social_accounts row carrying the Page access token — Page tokens minted from a long-lived user token do not expire, so these destinations never need a refresh; they die only if the user revokes the app, changes their password, or loses their Page role, at which point the publisher marks the row expired and the card asks for a reconnect.

Portal checklist (developers.facebook.com → app 798893679822212)

  • First request (2026-09-17): pages_manage_posts, instagram_basic, instagram_content_publish. Verdict 2026-09-26: instagram_basic approved, the other two rejected for the screencast.
  • Resubmission (2026-09-27): Advanced Access requested for the ten permissions listed under "Where things stand", each with the video that shows its own flow. In review; the filed text is in docs/meta-app-review-request-2026-09-27.md.
  • Use cases: the app has the three use cases that offer them — "Manage everything on your Page", "Manage messaging & content on Instagram" and "Capture & manage ad leads with Marketing API" (checked 2026-09-27).
  • Instagram product: added; the Instagram-Login app is KeyesCode Marketing OS-IG (1100462499149577), a child of the same Meta app.
  • Business verification: verified as a Tech Provider (2026-07-27).
  • Webhooks product → Page → leadgen (done 2026-09-27): callback https://marketing-api.keyescode.com/integrations/meta/webhooks, verify token = META_WEBHOOK_VERIFY_TOKEN on the API service, leadgen subscribed. A test lead from Meta's Lead Ads Testing Tool was delivered and reached the Leads page.
  • Data Use Checkup / Required actions / Alert Inbox: clear anything outstanding before submitting; an open required action blocks Submit.
  • App mode: the app has been Live since 2026-08-26, so nothing changes after approval — users without an app role can grant a permission as soon as Meta approves it. Existing connections must reconnect once to pick the new scopes up.
  • Already correct, just confirm: Valid OAuth Redirect URI https://marketing-api.keyescode.com/integrations/meta/callback, Deauthorize callback …/integrations/meta/deauthorize, Data Deletion Request URL …/integrations/meta/data-deletion, privacy policy and terms URLs, app icon, category Business.

Instagram-specific requirements

  • Instagram publishing only works for an Instagram professional account (Business or Creator) that is linked to a Facebook Page the connecting user manages. The picker shows an Instagram account only when /me/accounts returns an instagram_business_account for the Page. A personal Instagram account, or a professional one not linked to a Page, never appears — fix that in the Instagram app (Settings → Account type) and on the Page (Settings → Linked accounts) first, then click Reconnect Facebook on the card so the picker is re-read.
  • Meta fetches the media; the API never uploads Instagram bytes. The container call sends the asset's signed file URL on API_PUBLIC_URL (15 minute TTL). Meta must be able to reach it from its own servers, so local Instagram testing needs a tunnel (ngrok / cloudflared) with API_PUBLIC_URL set to the tunnel origin; a localhost URL fails at step 1 with Meta's "Media download failed". Facebook Page posts upload bytes instead and work without a tunnel.
  • Images post to the feed; every video is a Reel — the only video type the Content Publishing API accepts. Aspect ratio, duration and codec are judged by Meta while the container processes; a rejection comes back verbatim as the target's error.
  • 25 published posts per rolling 24 hours per Instagram account (Meta's content-publishing quota). Hitting it surfaces as "Instagram's daily publishing limit (25) reached for this account"; the count resets on Meta's side, there is nothing to configure.
  • Caption ≤ 2,200 characters and ≤ 30 hashtags — validated at compose time.

Instagram Login (no Page)

The second Meta integration for Instagram — Instagram API with Instagram Login — lets a Business or Creator account that is not linked to a Facebook Page connect directly on instagram.com. Both paths land in the same instagram destinations; the card's row caption says which one made it ("via Facebook" / "via Instagram login").

What the code does

  • The card's second button, Connect with Instagram, calls GET …/social/instagram/connect (org admin) and redirects to https://www.instagram.com/oauth/authorize with client_id=INSTAGRAM_APP_ID, redirect_uri=INSTAGRAM_REDIRECT_URI, response_type=code, scope=instagram_business_basic,instagram_business_content_publish (plus instagram_business_manage_comments when META_COMMENTS_SCOPE_ENABLED is on, as it is in production) and a 10-minute signed state. The button is disabled with a "not configured" hint until INSTAGRAM_APP_ID + INSTAGRAM_APP_SECRET are set.
  • The callback GET /integrations/instagram/callback exchanges the code at https://api.instagram.com/oauth/access_token (1-hour token), upgrades it at https://graph.instagram.com/access_token?grant_type=ig_exchange_token (60 days), reads graph.instagram.com/<ver>/me and stores one social_accounts row: externalId = the Instagram-scoped user_id, metadata: { authKind: 'instagram_login', username, accountType }, tokenExpiresAt = now + 60 days. A PERSONAL account is refused (?social=instagram_error) — publishing needs Business or Creator.
  • Tokens expire, unlike Page tokens. The Instagram adapter refreshes a token that is within 7 days of expiry (ig_refresh_token, another 60 days) the next time the account is published to; Instagram only honours a refresh on a token at least 24 h old. An account nobody publishes to for 60 days therefore lapses to expired and needs Reconnect on the card.
  • Publishing is the same container → status poll → media_publish sequence as the Facebook-Login path (same media rules, 25 posts / 24 h, 2,200-char caption, the public API_PUBLIC_URL tunnel requirement), with graph.instagram.com as the host and the account's own token.
  • A Page-linked account connected both ways is one row: Instagram Login's user_id is the same id as the Page's instagram_business_account (seen in production 2026-09-27), and accounts are keyed on (business, platform, external id). The last connect wins, and the card's caption ("via Facebook" / "via Instagram login") says which grant the row holds.

Portal checklist (developers.facebook.com → app 798893679822212)

  • Use case "Manage messaging & content on Instagram" is on the app, and Meta auto-created the companion Instagram app, ID 1100462499149577 (verified 2026-09-09). Its secret is under Use cases → Instagram → "API setup with Instagram login". That id + secret are INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET — not the main app's META_APP_ID / META_APP_SECRET.
  • On the same page, step 4 "Set up Instagram business login": add the OAuth redirect URI https://marketing-api.keyescode.com/integrations/instagram/callback (production) — done; @keyescode connected through it on 2026-09-09. Instagram is expected to insist on HTTPS — (verify) whether http://localhost:4000/integrations/instagram/callback is accepted for local testing; if not, register a tunnel URL (ngrok / cloudflared) and set INSTAGRAM_REDIRECT_URI to it.
  • Step 2 "Add account" on that page: add the owner's own Instagram professional account as a tester so it can connect while the app is in Development mode (the Instagram side of an app role).
  • Set INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET and INSTAGRAM_REDIRECT_URI in Railway (META_API_VERSION is shared) — set in production; the 2026-09-09 connect below needed all three.
  • App Review: instagram_business_basic and instagram_business_content_publish are "Ready for testing" (11 and 8 production API calls, 2026-09-22). They were taken out of the Facebook-Login request submitted 2026-09-17 and go in the next submission, which cannot open until that one has a verdict (Meta allows one pending submission). The usage text, Connect-with-Instagram reviewer steps, screencast and click path are ready in META_APP_REVIEW.md §5.4. Until approved, only app-role users can connect this way.
  • Verify: Settings → Integrations → Connect with Instagram → approve on instagram.com → land on ?social=instagram_connected with an Instagram row captioned "via Instagram login"; publish from the Social Poster — done on production 2026-09-09: the Reel https://www.instagram.com/reel/DdEvmy6icdh/ went out through the instagram_login row.

Facebook Page specifics

  • Text-only, one image, or one video per post (video up to the 200 MB asset cap, sent as one multipart upload to graph-video.facebook.com). Caption up to 63,206 characters.
  • A Graph error 200 / 10 / 283 is mapped to "Missing pages_manage_posts on this Page — reconnect Facebook publishing": in Development mode that is what a non-app-role user sees, and it is also what a Page whose admin did not tick the Page in the dialog sees.

2. TikTok

What the code does

  • Login Kit OAuth: GET …/social/tiktok/connect (org admin) redirects to https://www.tiktok.com/v2/auth/authorize/ with client_key, scope=user.info.basic,video.publish,video.upload, response_type=code, redirect_uri=TIKTOK_REDIRECT_URI and a 10-minute signed state. The callback GET /integrations/tiktok/callback exchanges the code at https://open.tiktokapis.com/v2/oauth/token/, reads /v2/user/info/, and stores one social_accounts row per TikTok user (externalId = open_id).
  • Tokens: access token 24 h, refresh token 365 days, rotated on every refresh — the API persists the new one. A refresh failure marks the account expired; the user reconnects from the card.
  • Content Posting API, Direct Post, source: FILE_UPLOAD. Publish = creator_info/query → video/init → chunked PUTs to the pre-signed upload_url (10 MB chunks, whole file when under 10 MB) → status/fetch every 15 s for up to 10 minutes. PULL_FROM_URL was deliberately not used: it requires the media URL's domain to be verified in the TikTok portal (URL properties), which would tie publishing to a verified API_PUBLIC_URL. FILE_UPLOAD needs no domain verification and works from localhost.
  • The creator's privacy_level_options gate every post: a privacy level TikTok does not offer fails before any byte is uploaded with TikTok only allows: <list>. The composer reads the same list first (GET …/social/tiktok/accounts/:accountId/creator-info) and offers only those levels, with no default — TikTok's Direct Post UX guidelines forbid one. The TikTok panel also carries the other audit requirements: creator nickname, "Allow" interaction boxes, the commercial content disclosure and the Music Usage Confirmation line.
  • One video per post (video/mp4, video/quicktime, video/webm, ≤ 200 MB asset cap; TikTok's own per-creator max_video_post_duration_sec applies). Caption ≤ 2,200 characters. Photo posts are out of scope.

Portal checklist (developers.tiktok.com)

  • Register / sign in as a TikTok developer (a TikTok account is required; the org's business TikTok account is a sensible owner).
  • Create an app ("Manage apps → Connect an app"). Fill in app name, description, icon, category, Terms of Service URL (https://marketing.keyescode.com/terms) and Privacy Policy URL (https://marketing.keyescode.com/privacy) — the portal will not submit an app for review without them (verify).
  • Platform: add Web and set the website URL to https://marketing.keyescode.com.
  • Add products: Login Kit and Content Posting API. In Content Posting API, enable Direct Post (the code uses Direct Post, not "Upload to inbox") (verify the toggle's current name).
  • Scopes: make sure user.info.basic, video.publish and video.upload are all listed under the app's scopes — Login Kit grants user.info.basic; Content Posting API adds video.publish (Direct Post) and video.upload (inbox). The consent screen only shows scopes the app is entitled to; a missing one makes the authorize call fail with scope_not_authorized (verify the exact error code).
  • Redirect URI: under Login Kit, register https://marketing-api.keyescode.com/integrations/tiktok/callback (this is TIKTOK_REDIRECT_URI in production). TikTok is expected to reject plain-HTTP / localhost redirect URIs on the Web platform (verify) — if it does, local connect testing needs a tunnel, with TIKTOK_REDIRECT_URI set to https://<tunnel>/integrations/tiktok/callback and that URI registered too.
  • Copy credentials: the app's Client key → TIKTOK_CLIENT_KEY, Client secret → TIKTOK_CLIENT_SECRET (API env; see §4).
  • Sandbox vs. production: a new app starts in sandbox. Sandbox mode only lets a small set of TikTok accounts you add as target users go through login and post, and everything posts as private (verify the current target-user limit). Add the owner's TikTok account as a target user to test. Production access requires submitting the app for review ("Submit for review" on the app page) — the review checks the app's description, demo video and that the requested scopes match what the app visibly does. Expect to attach a screencast of the connect flow and a publish from the Social Poster.
  • Content Posting API audit — status: approved Oct 6 2026 (resubmitted Sep 23 2026 with a hand-recorded walkthrough; the first application was denied for agent-recorded videos). TIKTOK_AUDITED=true is set on the prod API, and a PUBLIC_TO_EVERYONE post from a public account published the same day. For reference, an unaudited production client is held to TikTok's Direct Post restrictions: creator_info/query offers no public level (seen on a private account: FOLLOWER_OF_CREATOR, MUTUAL_FOLLOW_FRIENDS, SELF_ONLY), and it can only post to a TikTok account whose profile is set to Private (unaudited_client_can_only_post_to_private_accounts) — "Only me" on a public account is not enough. A new deployment with its own TikTok app starts there, and its connect card says so while TIKTOK_AUDITED is unset.
    • Apply / reapply: app page → Production → Content Posting API → Direct Post → Reapply (a 4-step form; the portal drops everything if the tab closes). Daily-cap answer: "Less than 100". Step 3 wants screen recordings of the TikTok authorization page, the Post-to-TikTok composer and what happens after posting, ≤ 5 MB each.
    • Recordings must be made by a person. The first application was denied because its videos were produced by an automated agent; TikTok's reviewer requires the flow to be walked through by hand — every click, interaction and transition — in real screen recordings. Do not reuse the agent-made tiktok-audit-*.mp4 files. The resubmission used one continuous walkthrough (connect → consent → compose → publish → the post on the profile), re-encoded to fit the 5 MB cap without cutting or speeding it up.
    • On approval (done Oct 6 2026): set TIKTOK_AUDITED=true on the API and restart. The card's private-visibility note disappears, and the composer offers "Everyone" with no code change — it only ever shows TikTok's own privacy_level_options, which then include PUBLIC_TO_EVERYONE. The private-profile requirement is lifted too, so the creator may switch the account back to public.
  • Rate limit: the Content Posting API allows 6 requests per minute per user access token across creator_info/query, video/init and status/fetch. The publisher spaces its status polls 15 s apart to stay inside it; do not schedule more than a couple of TikTok posts for the same minute on one account.

3. YouTube

What the code does

  • Uses the existing Google Cloud OAuth client (GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET) that already serves Google sign-in and the GA4 / Search Console connector. It is a third consent flow on the same client with its own redirect URI, GOOGLE_YOUTUBE_REDIRECT_URI, so the read-only GA4/GSC grant is never widened.
  • Consent requests https://www.googleapis.com/auth/youtube.upload, https://www.googleapis.com/auth/youtube.readonly, openid, email with access_type=offline and prompt=consent so a refresh token always comes back. A grant that returns no refresh token is refused (?social=youtube_error) rather than stored.
  • After the exchange the callback calls channels.list?part=snippet&mine=true and stores the channel as the destination. A Google account with no YouTube channel lands on ?social=no_channel — the user must create a channel on youtube.com first.
  • Publishing is the Data API v3 resumable videos.insert with snippet (title ≤ 100, description ≤ 5,000, tags, categoryId: '22') and status (privacyStatus, selfDeclaredMadeForKids — the COPPA declaration is always sent). Shorts are not a separate API: isShort appends #Shorts and YouTube files a vertical ≤ 3 minute video as a Short on its own. Titles and descriptions cannot contain < or >.
  • One video per post (video/mp4, video/quicktime, video/webm, ≤ 200 MB).
  • Comments (Engagement inbox) need https://www.googleapis.com/auth/youtube.force-ssl — the only scope commentThreads.list, comments.list and comments.insert accept; youtube.readonly is not enough. The consent flow adds it only when YOUTUBE_COMMENTS_SCOPE_ENABLED=true (default false). A channel whose stored grant lacks it is never sent to Google for comments: the inbox shows "YouTube comments cannot be read yet — this app needs the youtube.force-ssl permission", and — once the flag is on — a Reconnect link, with "Reconnect to enable comments and replies" on the Integrations card.

Console checklist (console.cloud.google.com → the project that owns the OAuth client)

  • APIs & Services → Library: enable YouTube Data API v3. Without it, channels.list fails at connect time (?social=youtube_error) and the API logs a 403 accessNotConfigured.
  • APIs & Services → Credentials → the OAuth 2.0 client: add https://marketing-api.keyescode.com/integrations/youtube/callback to Authorized redirect URIs (that is GOOGLE_YOUTUBE_REDIRECT_URI in production). For local development add http://localhost:4000/integrations/youtube/callback too — Google allows http://localhost redirect URIs.
  • OAuth consent screen → Scopes: add …/auth/youtube.upload and …/auth/youtube.readonly. Both are sensitive scopes. Save.
  • Publishing status (In production, confirmed 2026-09-22): if the consent screen is still Testing, only listed test users (≤ 100) can grant, and their refresh tokens expire after 7 days — a connected channel would flip to expired weekly. Move the app to In production before real users connect.
  • Verification (approved 2026-09-11 for project keyescode — both YouTube scopes; demo https://youtu.be/dgeHBRj5NGg; any consent-screen config change or new scope needs a new verification): an In-production app requesting sensitive scopes must submit for OAuth verification (brand verification: homepage, privacy policy at https://marketing.keyescode.com/privacy, a justification per scope, and a screencast of the consent flow + upload). Until it is granted, users see Google's "This app isn't verified" interstitial (they can still continue via "Advanced"), and the project is subject to Google's cap of 100 users (verify — the cap applies to unverified apps requesting sensitive scopes).
  • YouTube API Services compliance audit: separately from OAuth verification, YouTube locks uploads from unaudited API projects to private regardless of the requested privacyStatus. The video still goes up (the target shows published), but with a different privacy than requested — the adapter logs "YouTube stored the video with a different privacy status than requested (unverified app?)" when that happens. Request the audit via the YouTube API Services – Audit and Quota Extension Form (https://support.google.com/youtube/contact/yt_api_form) (verify the current form URL).
  • Quota: the default quota is 10,000 units per day per project. videos.insert costs 1,600 units, so the default allows about 6 uploads per day across every connected channel (channels.list at connect time costs 1). The publisher maps quotaExceeded to "Daily YouTube upload quota reached — try again after midnight Pacific". Request more through the same Audit and Quota Extension Form once real usage approaches that.
  • Nothing changes for Google sign-in or the GA4/GSC connector — do not add the YouTube scopes to those flows.

Turning on YouTube comments (youtube.force-ssl)

Adding a scope needs a new OAuth verification, and an unverified sensitive scope puts Google's "unverified app" warning on every new consent — so the code ships with the scope switched off.

  • Data Access: …/auth/youtube.force-ssl added as a sensitive scope on project keyescode (2026-09-24). The single "How will the scopes be used?" justification (1,000-char limit) was rewritten to cover both pending scopes — adwords and youtube.force-ssl (reading comments on the user's own videos in the Engagement inbox and posting replies they approve; both calls accept only force-ssl) — and to say analytics.readonly, youtube.readonly and youtube.upload are verified and unchanged.
  • Demo video (owner-recorded): the consent screen showing the new scope with the client_id in the address bar, the Engagement inbox reading a comment, and a reply posted and visible in YouTube Studio. Google says not to request an unverified scope from production traffic while recording: use a staging environment or a local API with YOUTUBE_COMMENTS_SCOPE_ENABLED=true. The demo must also cover adwords if both go in one submission.
  • Submit verification from the Verification Center, bundled with the pending adwords verification if it is still open.
  • After approval: set YOUTUBE_COMMENTS_SCOPE_ENABLED=true on the production api service, reconnect each YouTube channel (including KeyesCode) from Settings → Integrations, and confirm comments appear in the Engagement tab within 15 minutes (the collector's tick; no backfill job is needed). Rollback: set the flag back to false. New connects stop requesting the scope; existing grants keep it.

4. Railway go-live checklist (api service)

The API service is configured by apps/api/railway.json; the web service needs no change for this feature (no new NEXT_PUBLIC_* variables).

  • Variables on the api service — add:

    VariableValue
    TIKTOK_CLIENT_KEYfrom the TikTok app (§2)
    TIKTOK_CLIENT_SECRETfrom the TikTok app (§2)
    TIKTOK_REDIRECT_URIhttps://marketing-api.keyescode.com/integrations/tiktok/callback
    TIKTOK_AUDITEDtrue only once TikTok approves the Direct Post audit (§2)
    GOOGLE_YOUTUBE_REDIRECT_URIhttps://marketing-api.keyescode.com/integrations/youtube/callback
    YOUTUBE_COMMENTS_SCOPE_ENABLEDtrue only once Google verifies youtube.force-ssl (§3)

    Leave a variable unset rather than empty: Railway's variable editor does not accept an empty value (verify), and an empty *_REDIRECT_URI fails the API's z.string().url() validation at boot, while an empty TIKTOK_CLIENT_KEY simply keeps the connect card in "not configured".

  • Confirm already-required variables carry production values: META_APP_ID=798893679822212, META_APP_SECRET, META_REDIRECT_URI=https://marketing-api.keyescode.com/integrations/meta/callback, GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, WEB_URL=https://marketing.keyescode.com (every social callback redirects to ${WEB_URL}/settings/integrations), and — not in the original DEPLOYMENT.md table — API_PUBLIC_URL=https://marketing-api.keyescode.com. Its default is http://localhost:4000; with the default, every Instagram post fails at the container step because Meta cannot fetch the media, and asset links in the web app point at localhost.

  • Migration. The social_accounts, social_posts and social_post_targets tables come from apps/api/src/database/migrations/1786210000000-SocialPublishing.ts. The service's startCommand is pnpm --filter @marketing-os/api migration:run:prod && pnpm --filter @marketing-os/api start, so the migration runs on the first deploy of this branch before the server starts — confirm in the deploy log that SocialPublishing1786210000000 was executed. If the start command was ever changed to skip migrations, run it by hand from a Railway shell:

    sh
    pnpm --filter @marketing-os/api migration:run:prod
  • Redeploy after setting the variables (Railway restarts the service on a variable change; the env schema is read at boot).

  • https://marketing-api.keyescode.com/health → {"status":"ok"}.

  • Queues: sign in as an address listed in PLATFORM_ADMIN_EMAILS and open https://marketing-api.keyescode.com/admin/queues; social-publish must appear alongside the other queues. A scheduled post that never publishes is either the sweeper not running (check the log for social-post-scheduler) or a job failing on this board.

  • Sentry: nothing to configure, but the first real publish failures will show up there with the target id — worth watching during the verification pass below.


5. Verification matrix

One manual publish per platform, from Settings → Integrations (connect) and the Social Poster page (compose). Do the Meta rows first in Development mode with the owner's own accounts — they need no approval to work for an app-role user.

#PlatformPre-conditionsDoExpectedKnown caveats
1Facebook PageConnecting user has an app role on app 798893679822212 and is admin of the PageConnect with Facebook → grant every scope the dialog lists → tick the Page → Save accounts. Compose a text post with a link, then one with an image.Card lists the Page as connected. Both targets reach published; the History row links to facebook.com/<pageId>_<postId> and the post is visible on the Page.Dialog shows only scopes the app may request; a missing pages_manage_posts grant surfaces later as "Missing pages_manage_posts on this Page — reconnect Facebook publishing".
2InstagramRow 1 done; the Page has a linked Instagram professional account; API_PUBLIC_URL is publicly reachable (production, or a tunnel locally)In the picker tick the Instagram account → Save. Compose one JPEG image post, then one MP4 vertical video (Reel).Image publishes within ~30 s; the Reel within 1–3 minutes (container polling). History links to the instagram.com/p/… or /reel/… permalink.localhost API_PUBLIC_URL → "Media download failed". Bad aspect ratio / codec → Meta's status text verbatim. 26th post in 24 h → "daily publishing limit (25) reached".
3TikTokTikTok app credentials set; the owner's TikTok account is a sandbox target user (or the app is in production)Connect TikTok → authorize. Compose one MP4 video with "Who can view" = Everyone (Only me on an unaudited app).Target reaches published after PUBLISH_COMPLETE (typically 30 s – 2 min); the video appears in the creator's profile. externalUrl is null for a private post, and for a public one when TikTok has not returned a public post id by PUBLISH_COMPLETE (seen on the first public post, Oct 6 2026).Unaudited app: any level other than SELF_ONLY fails with TikTok only allows: SELF_ONLY. Over 6 API calls/min on one token → TikTok rate-limit error; wait a minute.
4YouTubeYouTube Data API v3 enabled, redirect URI registered, consent screen scopes added; the Google account owns a channelConnect YouTube → grant both scopes. Compose one MP4 video with a title, privacy = Public, isShort on for a vertical clip.Target reaches published; History links to youtube.com/shorts/<id> (or watch?v=<id>); the video is on the channel.Unaudited project: video is stored private whatever was requested (adapter logs the mismatch). 7th upload of the day → quota error until midnight Pacific.
5SchedulingAny connected accountCompose to one account with a schedule 2–3 minutes out.Post sits in Scheduled, flips to publishing within a minute of the time, then published.Requires the API process to be running the sweeper; check /admin/queues if it never moves.
6DisconnectA connected account with no pending / publishing targetsClick Disconnect on the account in its card and confirm the prompt.Row disappears; a later compose no longer offers it.409 "Cancel or wait for scheduled posts to this account first" while anything is in flight — cancel the scheduled post first.

Record what each row produced (post URLs, timings, any error text) — rows 1–2 double as the evidence for the Meta App Review screencast, and rows 3–4 for the TikTok review and Google verification submissions.


Open items this document cannot settle

  • Whether Meta's current dashboard offers pages_manage_posts / instagram_content_publish under the existing use case or requires adding a new one, and whether business verification is already satisfied — read off the dashboard when submitting.
  • TikTok's exact sandbox target-user limit, redirect-URI HTTPS rule, and the current wording of the Direct Post toggle and audit request.
  • Whether Google still enforces the 100-user cap on unverified sensitive-scope apps and the current Audit and Quota Extension Form URL.

Each is marked (verify) at the point where it matters above; the code does not depend on any of them.


Something wrong or missing on this page? Tell us and we will fix it.