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
| Platform | Env vars (apps/api/src/config/env.ts) | Connect (org admin only) | Callback = redirect URI to register | Consent scopes requested |
|---|---|---|---|---|
| Facebook Pages | META_APP_ID, META_APP_SECRET, META_REDIRECT_URI, META_API_VERSION | GET /organizations/:orgId/businesses/:businessId/social/meta/connect | GET /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 |
| same as Facebook (an Instagram destination publishes through its linked Page) | same dialog; picked via GET …/social/meta/pages + POST …/social/meta/select | same | same | |
| Instagram Login (no Page) | INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, INSTAGRAM_REDIRECT_URI (+ META_API_VERSION) | GET /organizations/:orgId/businesses/:businessId/social/instagram/connect | GET /integrations/instagram/callback | instagram_business_basic,instagram_business_content_publish + instagram_business_manage_comments when META_COMMENTS_SCOPE_ENABLED is on |
| TikTok | TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET, TIKTOK_REDIRECT_URI | GET /organizations/:orgId/businesses/:businessId/social/tiktok/connect | GET /integrations/tiktok/callback | user.info.basic,video.publish,video.upload |
| YouTube | GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_YOUTUBE_REDIRECT_URI | GET /organizations/:orgId/businesses/:businessId/social/youtube/connect | GET /integrations/youtube/callback | https://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 (
assetstable); uploads are capped at 200 MB per file (apps/api/src/modules/assets). - Public API URL.
API_PUBLIC_URLis 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 fromlocalhost. - 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_URLmust 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-publishBullMQ queue inside the API process; a scheduled post is picked up by a per-minute sweeper. Both are visible at/admin/queues(gated byPLATFORM_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 andpublic_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 (submission880842051627374). Verdict, recorded 2026-09-26 and read back from the portal on 2026-09-27:instagram_basicwas approved;pages_manage_postsandinstagram_content_publishwere 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 inMETA_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:
Feature Permissions waiting Publishing through Facebook Login pages_manage_posts,instagram_content_publishPublishing through Connect with Instagram instagram_business_basic,instagram_business_content_publishEngagement inbox (comments and replies) pages_manage_engagement,instagram_manage_comments,instagram_business_manage_commentsLead ads (ads connect dialog) leads_retrieval,pages_manage_ads,pages_manage_metadataAll 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 inMETA_APP_REVIEW.md→ §5.6 (MARK-O4F4FX). -
read_insights,instagram_manage_insights,instagram_business_manage_insightsandcatalog_managementare 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.buildPublishOAuthUrlwith the scope stringpages_show_list,pages_read_engagement,pages_manage_posts,instagram_basic,instagram_content_publishand a signedstatewhose purpose ismeta_publish. WhenMETA_COMMENTS_SCOPE_ENABLEDis on — it is in production, since 2026-09-25 — the string also carriespages_manage_engagement,instagram_manage_commentsfor 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/callbackin production, already registered as a Valid OAuth Redirect URI). The same callback serves the ads dialog and the publishing dialog; it branches on thestatepurpose. 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_connectionstoken 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 linkedinstagram_business_account, if any) and the admin ticks which Pages / Instagram accounts to enable. Each pick becomes asocial_accountsrow 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 rowexpiredand 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_basicapproved, 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): callbackhttps://marketing-api.keyescode.com/integrations/meta/webhooks, verify token =META_WEBHOOK_VERIFY_TOKENon the API service,leadgensubscribed. 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/accountsreturns aninstagram_business_accountfor 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) withAPI_PUBLIC_URLset to the tunnel origin; alocalhostURL 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 tohttps://www.instagram.com/oauth/authorizewithclient_id=INSTAGRAM_APP_ID,redirect_uri=INSTAGRAM_REDIRECT_URI,response_type=code,scope=instagram_business_basic,instagram_business_content_publish(plusinstagram_business_manage_commentswhenMETA_COMMENTS_SCOPE_ENABLEDis on, as it is in production) and a 10-minute signedstate. The button is disabled with a "not configured" hint untilINSTAGRAM_APP_ID+INSTAGRAM_APP_SECRETare set. - The callback
GET /integrations/instagram/callbackexchanges the code athttps://api.instagram.com/oauth/access_token(1-hour token), upgrades it athttps://graph.instagram.com/access_token?grant_type=ig_exchange_token(60 days), readsgraph.instagram.com/<ver>/meand stores onesocial_accountsrow:externalId= the Instagram-scopeduser_id,metadata: { authKind: 'instagram_login', username, accountType },tokenExpiresAt= now + 60 days. APERSONALaccount 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 toexpiredand needs Reconnect on the card. - Publishing is the same container → status poll →
media_publishsequence as the Facebook-Login path (same media rules, 25 posts / 24 h, 2,200-char caption, the publicAPI_PUBLIC_URLtunnel requirement), withgraph.instagram.comas the host and the account's own token. - A Page-linked account connected both ways is one row: Instagram Login's
user_idis the same id as the Page'sinstagram_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 areINSTAGRAM_APP_ID/INSTAGRAM_APP_SECRET— not the main app'sMETA_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) whetherhttp://localhost:4000/integrations/instagram/callbackis accepted for local testing; if not, register a tunnel URL (ngrok / cloudflared) and setINSTAGRAM_REDIRECT_URIto 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_SECRETandINSTAGRAM_REDIRECT_URIin Railway (META_API_VERSIONis shared) — set in production; the 2026-09-09 connect below needed all three. - App Review:
instagram_business_basicandinstagram_business_content_publishare "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 inMETA_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_connectedwith an Instagram row captioned "via Instagram login"; publish from the Social Poster — done on production 2026-09-09: the Reelhttps://www.instagram.com/reel/DdEvmy6icdh/went out through theinstagram_loginrow.
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 tohttps://www.tiktok.com/v2/auth/authorize/withclient_key,scope=user.info.basic,video.publish,video.upload,response_type=code,redirect_uri=TIKTOK_REDIRECT_URIand a 10-minute signedstate. The callbackGET /integrations/tiktok/callbackexchanges the code athttps://open.tiktokapis.com/v2/oauth/token/, reads/v2/user/info/, and stores onesocial_accountsrow 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→ chunkedPUTs to the pre-signedupload_url(10 MB chunks, whole file when under 10 MB) →status/fetchevery 15 s for up to 10 minutes.PULL_FROM_URLwas 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 verifiedAPI_PUBLIC_URL.FILE_UPLOADneeds no domain verification and works fromlocalhost. - The creator's
privacy_level_optionsgate every post: a privacy level TikTok does not offer fails before any byte is uploaded withTikTok 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-creatormax_video_post_duration_secapplies). 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.publishandvideo.uploadare all listed under the app's scopes — Login Kit grantsuser.info.basic; Content Posting API addsvideo.publish(Direct Post) andvideo.upload(inbox). The consent screen only shows scopes the app is entitled to; a missing one makes the authorize call fail withscope_not_authorized(verify the exact error code). - Redirect URI: under Login Kit, register
https://marketing-api.keyescode.com/integrations/tiktok/callback(this isTIKTOK_REDIRECT_URIin production). TikTok is expected to reject plain-HTTP /localhostredirect URIs on the Web platform (verify) — if it does, local connect testing needs a tunnel, withTIKTOK_REDIRECT_URIset tohttps://<tunnel>/integrations/tiktok/callbackand 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=trueis set on the prod API, and aPUBLIC_TO_EVERYONEpost from a public account published the same day. For reference, an unaudited production client is held to TikTok's Direct Post restrictions:creator_info/queryoffers 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 whileTIKTOK_AUDITEDis 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-*.mp4files. 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=trueon 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 ownprivacy_level_options, which then includePUBLIC_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/initandstatus/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,emailwithaccess_type=offlineandprompt=consentso 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=trueand 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.insertwithsnippet(title ≤ 100, description ≤ 5,000, tags,categoryId: '22') andstatus(privacyStatus,selfDeclaredMadeForKids— the COPPA declaration is always sent). Shorts are not a separate API:isShortappends#Shortsand 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 scopecommentThreads.list,comments.listandcomments.insertaccept;youtube.readonlyis not enough. The consent flow adds it only whenYOUTUBE_COMMENTS_SCOPE_ENABLED=true(defaultfalse). 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.listfails at connect time (?social=youtube_error) and the API logs a 403accessNotConfigured. - APIs & Services → Credentials → the OAuth 2.0 client: add
https://marketing-api.keyescode.com/integrations/youtube/callbackto Authorized redirect URIs (that isGOOGLE_YOUTUBE_REDIRECT_URIin production). For local development addhttp://localhost:4000/integrations/youtube/callbacktoo — Google allowshttp://localhostredirect URIs. - OAuth consent screen → Scopes: add
…/auth/youtube.uploadand…/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
expiredweekly. 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 athttps://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
privateregardless of the requestedprivacyStatus. The video still goes up (the target showspublished), 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.insertcosts 1,600 units, so the default allows about 6 uploads per day across every connected channel (channels.listat connect time costs 1). The publisher mapsquotaExceededto "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-ssladded as a sensitive scope on projectkeyescode(2026-09-24). The single "How will the scopes be used?" justification (1,000-char limit) was rewritten to cover both pending scopes —adwordsandyoutube.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 sayanalytics.readonly,youtube.readonlyandyoutube.uploadare verified and unchanged. - Demo video (owner-recorded): the consent screen showing the new scope
with the
client_idin 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 withYOUTUBE_COMMENTS_SCOPE_ENABLED=true. The demo must also coveradwordsif both go in one submission. - Submit verification from the Verification Center, bundled with the
pending
adwordsverification if it is still open. - After approval: set
YOUTUBE_COMMENTS_SCOPE_ENABLED=trueon the productionapiservice, 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 tofalse. 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
apiservice — add:Variable Value TIKTOK_CLIENT_KEYfrom the TikTok app (§2) TIKTOK_CLIENT_SECRETfrom the TikTok app (§2) TIKTOK_REDIRECT_URIhttps://marketing-api.keyescode.com/integrations/tiktok/callbackTIKTOK_AUDITEDtrueonly once TikTok approves the Direct Post audit (§2)GOOGLE_YOUTUBE_REDIRECT_URIhttps://marketing-api.keyescode.com/integrations/youtube/callbackYOUTUBE_COMMENTS_SCOPE_ENABLEDtrueonly once Google verifiesyoutube.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_URIfails the API'sz.string().url()validation at boot, while an emptyTIKTOK_CLIENT_KEYsimply 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 originalDEPLOYMENT.mdtable —API_PUBLIC_URL=https://marketing-api.keyescode.com. Its default ishttp://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_postsandsocial_post_targetstables come fromapps/api/src/database/migrations/1786210000000-SocialPublishing.ts. The service'sstartCommandispnpm --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 thatSocialPublishing1786210000000was executed. If the start command was ever changed to skip migrations, run it by hand from a Railway shell:shpnpm --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_EMAILSand openhttps://marketing-api.keyescode.com/admin/queues;social-publishmust appear alongside the other queues. A scheduled post that never publishes is either the sweeper not running (check the log forsocial-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.
| # | Platform | Pre-conditions | Do | Expected | Known caveats |
|---|---|---|---|---|---|
| 1 | Facebook Page | Connecting user has an app role on app 798893679822212 and is admin of the Page | Connect 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". |
| 2 | Row 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". | |
| 3 | TikTok | TikTok 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. |
| 4 | YouTube | YouTube Data API v3 enabled, redirect URI registered, consent screen scopes added; the Google account owns a channel | Connect 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. |
| 5 | Scheduling | Any connected account | Compose 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. |
| 6 | Disconnect | A connected account with no pending / publishing targets | Click 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_publishunder 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.