Developer docs
Reference

Integrations

OpenSEO, Agent-Reach, and the rest of what we consume rather than build.

docs/INTEGRATIONS.md

What we consume rather than build, and why each one earns its place.

The bar: an integration has to do something we cannot reasonably do ourselves, or do it so much cheaper that building it would be indulgent. Everything else we build, because a dependency is a thing that can break in a way we cannot fix.

OpenSEO — SEO, competitive and AI-visibility data

github.com/every-app/open-seo · MIT · self-hostable · exposes an MCP server

What it gives usKeyword research, rank tracking, SERP results, backlink profile, domain and competitor overview, and AI visibility — share of voice, cited sources, prompt explorer
What it costsBring-your-own DataForSEO key, pay per call. No subscription.
How we reach itIts MCP server — the same path our agents already speak, so there is no bespoke client to maintain
ReplacesThe planned SEMrush integration (F-091), and the hand-rolled AEO metric

Why it changed the plan

Two things, and the second is the bigger one.

Cost and lock-in. SEMrush is a subscription for data we would use in bursts. OpenSEO is MIT, self-hostable, and priced per call through DataForSEO. On the axis that matters — can we stop paying without losing the capability — it is strictly better.

It answers LAB-008. That lab was open on "what is the AEO metric?", because ranking has position and citation has nothing equivalent. OpenSEO's ai-search feature implements shareOfVoice, citedSources and promptExplorer — running a panel of prompts against assistants and measuring who gets cited. That is real infrastructure we were about to approximate with heuristics, and approximating it badly would have been worse than not measuring it.

What we still build ourselves

The boundary is on-page versus off-page.

OursOpenSEO
On-page technical audit✅ pure functions over crawled pages—
Feeds our findings model and the fix-PR pipeline (F-095)✅—
Keyword volumes, difficulty, SERP—✅
Rank tracking over time—✅
Backlinks, competitor domains—✅
AI visibility / citation rate—✅

Our audit stays because it is a few hundred lines of pure functions with no API cost, it runs on every crawl, and — the actual reason — its findings carry stable rule ids that map to fixes we open as pull requests. An external audit cannot know about our projects, our approval gate, or our repo.

Off-page data is the opposite: it needs a SERP crawler, a backlink index and a prompt panel. We are not building those.

See D-023. Tracked as F-091 (revised) and F-094.

Agent-Reach — the agents' read layer

github.com/Panniantong/Agent-Reach · MCP server

Unified read and search across ~14 platforms — X, Reddit, LinkedIn, YouTube, GitHub, RSS, podcasts, web search — by routing to native CLI tools with ordered fallbacks.

Read-only by design, which is the right boundary: publishing stays with our own channel adapters, behind the approval gate. Adopting it means not maintaining a scraper for every platform whose access path breaks quarterly. See D-017, F-098.

The rest

IntegrationForStatus
StripeSubscriptions, checkout, portal, webhooks✅ F-016
Google / GitHub OAuthSign-in; the GitHub consent also backs the repo integration✅ F-015
trigger.devDurable agent runs. Falls back to our own worker so the approval gate never depends on it✅ F-097
RabbitMQShort-lived, high-fan-out jobs✅ F-096
Google Ads · Meta AdsCampaign read/write, spend🔲 F-050 · F-051
Meta (Instagram + Facebook Pages)Organic publish, comments, insights — see the Meta app record below🟡 connected in dev; App Review pending
GA4Site-side truth to reconcile against platform-reported numbers🔲 F-064
WhatsApp (Baileys)Templates, broadcasts, conversations. ⚠️ unofficial client — see D-015🔲 F-048
GroqDrafting🔲 F-031
ElevenLabsVoiceover🔲 F-032
ApolloB2B enrichment. Personal data: lawful basis before it ships🔲 F-092

How an integration is wired

Always the same shape, so swapping one is a one-file change:

  1. Config in core/config.ts — an absent key disables the feature rather than crashing.
  2. A client in the feature's infra layer. Nothing else calls the vendor.
  3. An interface the rest of the code depends on, not the vendor's shape.
  4. A stub for tests. No live calls, ever — several of these bill per request.

The interface is the point. Baileys is behind the same ChannelProvider as everything else precisely so the official WhatsApp API is a transport swap rather than a rewrite.

Where platform credentials live

.env is no longer the only source. Adapter credentials resolve project row → deployment default row → env, so a project can install its own OAuth app without a redeploy.

The scope is the project, not the organization, since F-142. One app per org meant an agency's six clients all published through the same Meta app: one client's app review, rate limit or suspension was every client's, and nothing stopped a post for client A going out through client B's app. The migration copied each org row onto every project in that org, so nothing changed the day it landed; from then on the rows are independent.

Tablechannel_credentials — indexed, not unique, on (project_id, provider) since F-152: a project may register several apps for a channel platform (Google Ads/Analytics stay one). The first by created_at is the project's primary. Each connected account stores the app that connected it in social_accounts_connected.channel_credential_id and always resolves through it. project_id = NULL is the single deployment-wide default row
Encryptioncore/crypto.ts AES-256-GCM, purpose platform-credential (a different derived key than the oauth-token one guarding user tokens)
Written byPUT /projects/:projectId/channel-credentials/:provider (the primary) and `POST
Read byGET /projects/:projectId/channel-credentials — project membership is enough. A contributor who cannot publish still needs to see whether the app is configured, because that is the answer to "why did my post not go out"
Never returnedthe secret. Reads carry client_id + secret_last4 only
Default rownot writable through the API by design — deployment-wide credentials stay in .env, out of reach of anything holding a browser session
Resolutionmodules/channels/credentials.ts::resolveAdapter(key, projectId, credentialId?) — account-scoped callers use resolveAdapterForAccount(key, account). A named app wins; otherwise the project's primary; then the deployment default. 60s TTL cache per app, invalidated on write
Never inheriteda project with no app of its own is reported not configured for this project, carrying credential_source: "global" — never quietly handed a sibling project's row
Listed byGET /channels?project_id=… — project_id is required rather than defaulted; a default would silently report some other project's setup
UIProject → Integrations → Platform apps (apps/web/src/app/(app)/projects/[id]/integrations/platform-apps-panel.tsx)

providers: meta (backs both facebook and instagram — one Meta app, one row), linkedin, threads, pinterest, tiktok, x, youtube, google-analytics, google-ads. Field labels are per-provider: TikTok's pair is Client Key / Client Secret, because Login Kit for Business calls it a client key and using the platform's own word is the difference between a 30-second setup and a support ticket.

google-ads is the one provider with a third field. The Ads API refuses any request that does not carry a developer token from the API Center, so developer_token is stored beside the pair in its own encrypted column (developer_token + developer_token_last4, same AES-256-GCM purpose as the client secret) and is write-only in exactly the same way. It is separate from google-analytics rather than sharing that row because the two need different Cloud scopes and different approval. upsertProjectCredential rejects a first save that omits it (422) and treats a blank field on a re-save as keep the current token, matching how the client secret already behaves — the alternative is an incomplete row that saves cleanly and then resolves to the mock adapter at connect time.

The OAuth callback is unauthenticated, so the acting project is carried inside the signed state JWT (OAuthPending.projectId) — the code must be exchanged against the same client secret that built the auth URL. The publish, metrics and inbox paths resolve through resolveAdapterForProject for the same reason: authorizing with one project's Meta app and publishing through another's is rejected by Meta, with an error that points nowhere near the cause.

A project that brings its own app owns its own app review. Riding the deployment's shared app inherits its approval; installing your own does not.

Meta app of record

Written down because it wasn't, and a working day went into rediscovering it. A Meta app is owned by a person's developer account unless it is deliberately attached to a business portfolio, so "which login owns this?" is not answerable from the code or from .env.

App ID1072079535407131 (in .env as META_CLIENT_ID)
App nameSelf-Publish.ai
Business portfolioSelf-Publish.ai
Facebook PageSelf-Publish.ai — 1194643080408585
Instagram@selfpublish.ai, linked to that Page (the adapter reaches IG through the Page's instagram_business_account edge)
Use cases enabledManage messaging & content on Instagram · Manage everything on your Page
Insights permissioninstagram_manage_insights added to the use case on 2026-08-20. Reach, impressions, saves and shares depend on it; likes and comments do not. Tokens minted before that date do not carry it — those connections must be reconnected
ModeDevelopment — only accounts with a role on the app can connect. Client accounts need App Review + Business Verification
Redirect URIsNone registered: Meta auto-allows http://localhost in development mode and rejects it if you add it explicitly. A non-localhost host (tunnel or prod) must be added as https://<host>/api/v1/oauth/<adapter>/callback

Superseded: app 1503896745100539. Every Graph call against it returns "API access blocked." and nobody on the team could reach its dashboard to find out why — its credentials were in .env with no record of the owning account. Replaced rather than recovered.

Invalid Scopes usually means "not added", not "misnamed". The adapter's scope list has twice been rejected by the live dialog — first instagram_manage_insights, then the instagram_* set when the app was configured under the newer use-case model. Both times the scope was removed from the code on the theory that the name was wrong for the app's flavour.

For instagram_manage_insights that theory was wrong, and it cost the analytics page two rounds of staying blank. The name is the Instagram API with Facebook Login one, and that is the flavour the adapter speaks end to end — facebook.com/dialog/oauth, graph.facebook.com, Instagram through the Page's instagram_business_account edge. Had the vocabulary been the problem, instagram_basic and pages_show_list would fail too, and publishing has worked throughout. The permission was simply absent from the app's use case, so there was nothing to grant.

The authority is the app's own Use cases → Permissions and features list, not the docs and not this file. Check the scope is present and added there before concluding it is misnamed — and remember that adding it grants nothing to tokens already issued.

Webhooks are a TWO-step subscription, and the dashboard only shows one of them

An empty inbox on a correctly-configured app is almost always this. Meta requires both, and reports neither as missing:

StepWhereWho does itEvidence when done
1. The app subscribes to the fieldsMeta dashboard → Webhooks → object instagram / page → tick comments, messages, feedA human, once per appThe dashboard row reads Subscribed
2. The account subscribes to the appPOST /{page-id}/subscribed_apps — ChannelAdapter.subscribeWebhooks, run at the end of every OAuth connectUs, once per connected accountprovider_meta.webhookSubscription.ok === true on the SocialAccount

Step 1 alone delivers nothing. The verify handshake still succeeds, the dashboard still says Subscribed, and no error is raised anywhere — which is exactly what makes it expensive to find. Until 2026-09-09 step 2 did not exist in this codebase at all.

Three details of step 2 that are each independently able to make it fail quietly:

  • The node is the PAGE, not the IG user, and the host follows the login flavour. Three shapes exist under the one name subscribed_apps, and two production round trips were spent picking the wrong ones:

    AttemptCallWhat production answered
    1graph.instagram.com/{page-id}190 Cannot parse access token — that host wants an Instagram User token; a Facebook-Login connection only holds a Page token
    2graph.facebook.com/{ig-user-id}(#3) Application does not have the capability to make this API call — subscribed_apps is not an edge of the IG user node
    3graph.facebook.com/{page-id}the documented Facebook-Login form: installing the app on the Page is what makes Instagram events for the linked account flow

    The adapter now tries 3 first (Instagram fields, then a plain feed install if Meta rejects those as not-Page-fields), and keeps 1 as a last resort so an app later reconfigured for Instagram Login still works. Whichever attempt is accepted — host, id and fields — is recorded in provider_meta.webhookSubscription.target, so the question is answered by data rather than by another deploy.

  • The token is the PAGE token, never the user token — on either edge.

  • Facebook Pages subscribe on graph.facebook.com/{page-id}/subscribed_apps, which is a different subscription from the Instagram one above.

Permissions it needs, both added to the use case on 2026-09-09: pages_manage_metadata (without it the subscribe call answers (#200) Requires pages_manage_metadata permission) and instagram_manage_messages (without it Meta will not deliver the messages field at all). As always, a granted permission does not reach an already-issued token — existing connections must be reconnected, or re-subscribed via POST /api/v1/channels/:accountId/resubscribe once their token carries the scope.

Replying to a Facebook Page comment needs pages_manage_engagement (POST /{comment-id}/comments with the Page token). Receiving comments does not — they arrive on feed under pages_read_user_content — so the gap only shows when someone presses Reply, as (#200) from Meta. Requested from 2026-09-29 in Facebook's inboxScopes; it must be added to the use case before that ships, or the connect dialog fails with Invalid Scopes, and existing Pages must reconnect. Instagram comment replies ride on instagram_manage_comments, already requested.

How a Meta error is read (channels/adapters/meta-graph-errors.ts, shared by the Facebook and Instagram adapters). The HTTP status cannot separate a dead token from a missing permission — Meta sends both as 400 or 403 — so the Graph error body's code decides:

Graph errorAdapter codeWhat the caller does
code 190 or 102, any status (subcode 463 → token_expired)token_revoked / token_expiredmarks the account disconnected; the inbox answers 409
code 4, 17, 32, 613, or HTTP 429rate_limitedback off and retry
code 10 or 200–299, or a message about a permissioninvalid_request, naming the permissionaccount stays connected; the inbox answers 422 with "… needs the <permission> permission …"
unparseable 401token_revokeddisconnect
unparseable 403invalid_requestaccount stays connected
5xxplatform_errorretry

A social-account problem is never a 401 from our api: the web client reads any 401 as the user's own session ending and logs them out.

Facebook Page DMs are off, on purpose. They need pages_messaging, which belongs to Messenger — a capability neither of this app's use cases includes, so the permission does not appear in its list at all. It is not requested, and the Page subscribes to feed only. That is not timidity: the authorize dialog rejects the entire request with Invalid Scopes when asked for a permission the app cannot grant, so carrying it "for later" would break Facebook connect outright, and subscribed_apps likewise refuses the whole call, which would take Page comments down alongside the DMs. Instagram comments and DMs are unaffected — they run on instagram_manage_comments and instagram_manage_messages. To turn Page DMs on: add the Messenger product to the app and pages_messaging to its use case, set META_FACEBOOK_DMS=1 (which adds pages_messaging to the Facebook dialog and messages to the Page subscription — for org Platform apps too), then reconnect each Page. Unset, Facebook behaves exactly as above.

Answering DMs (Instagram and, once enabled, Facebook) goes through the Page's Send API: POST graph.facebook.com/v21.0/{page-id}/messages with the Page token, body { recipient: { id: <IGSID|PSID> }, message: { text }, messaging_type: "RESPONSE" } — the recipient is the sender.id the incoming webhook carried, stored as the item's author_handle. Meta allows it only within 24 hours of the customer's last message; outside that it answers (#10) subcode 2534022 (Instagram) / 2018278 (Messenger), which the inbox shows as "… only allows replies within 24 hours of the customer's last message." Instagram also needs Allow access to messages switched on in the Instagram app's Settings → Privacy → Messages → Connected tools, or the Send API refuses the call. Replies are adapters/meta-messaging.ts.