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 us | Keyword research, rank tracking, SERP results, backlink profile, domain and competitor overview, and AI visibility — share of voice, cited sources, prompt explorer |
| What it costs | Bring-your-own DataForSEO key, pay per call. No subscription. |
| How we reach it | Its MCP server — the same path our agents already speak, so there is no bespoke client to maintain |
| Replaces | The 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.
| Ours | OpenSEO | |
|---|---|---|
| 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
| Integration | For | Status |
|---|---|---|
| Stripe | Subscriptions, checkout, portal, webhooks | ✅ F-016 |
| Google / GitHub OAuth | Sign-in; the GitHub consent also backs the repo integration | ✅ F-015 |
| trigger.dev | Durable agent runs. Falls back to our own worker so the approval gate never depends on it | ✅ F-097 |
| RabbitMQ | Short-lived, high-fan-out jobs | ✅ F-096 |
| Google Ads · Meta Ads | Campaign 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 |
| GA4 | Site-side truth to reconcile against platform-reported numbers | 🔲 F-064 |
| WhatsApp (Baileys) | Templates, broadcasts, conversations. ⚠️ unofficial client — see D-015 | 🔲 F-048 |
| Groq | Drafting | 🔲 F-031 |
| ElevenLabs | Voiceover | 🔲 F-032 |
| Apollo | B2B 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:
- Config in
core/config.ts— an absent key disables the feature rather than crashing. - A client in the feature's
infralayer. Nothing else calls the vendor. - An interface the rest of the code depends on, not the vendor's shape.
- 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.
| Table | channel_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 |
| Encryption | core/crypto.ts AES-256-GCM, purpose platform-credential (a different derived key than the oauth-token one guarding user tokens) |
| Written by | PUT /projects/:projectId/channel-credentials/:provider (the primary) and `POST |
| Read by | GET /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 returned | the secret. Reads carry client_id + secret_last4 only |
| Default row | not writable through the API by design — deployment-wide credentials stay in .env, out of reach of anything holding a browser session |
| Resolution | modules/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 inherited | a 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 by | GET /channels?project_id=… — project_id is required rather than defaulted; a default would silently report some other project's setup |
| UI | Project → 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 ID | 1072079535407131 (in .env as META_CLIENT_ID) |
| App name | Self-Publish.ai |
| Business portfolio | Self-Publish.ai |
| Facebook Page | Self-Publish.ai — 1194643080408585 |
@selfpublish.ai, linked to that Page (the adapter reaches IG through the Page's instagram_business_account edge) | |
| Use cases enabled | Manage messaging & content on Instagram · Manage everything on your Page |
| Insights permission | instagram_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 |
| Mode | Development — only accounts with a role on the app can connect. Client accounts need App Review + Business Verification |
| Redirect URIs | None 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:
| Step | Where | Who does it | Evidence when done |
|---|---|---|---|
| 1. The app subscribes to the fields | Meta dashboard → Webhooks → object instagram / page → tick comments, messages, feed | A human, once per app | The dashboard row reads Subscribed |
| 2. The account subscribes to the app | POST /{page-id}/subscribed_apps — ChannelAdapter.subscribeWebhooks, run at the end of every OAuth connect | Us, once per connected account | provider_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:Attempt Call What production answered 1 graph.instagram.com/{page-id}190 Cannot parse access token— that host wants an Instagram User token; a Facebook-Login connection only holds a Page token2 graph.facebook.com/{ig-user-id}(#3) Application does not have the capability to make this API call—subscribed_appsis not an edge of the IG user node3 graph.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
feedinstall 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 inprovider_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 error | Adapter code | What the caller does |
|---|---|---|
code 190 or 102, any status (subcode 463 → token_expired) | token_revoked / token_expired | marks the account disconnected; the inbox answers 409 |
code 4, 17, 32, 613, or HTTP 429 | rate_limited | back off and retry |
code 10 or 200–299, or a message about a permission | invalid_request, naming the permission | account stays connected; the inbox answers 422 with "… needs the <permission> permission …" |
| unparseable 401 | token_revoked | disconnect |
| unparseable 403 | invalid_request | account stays connected |
| 5xx | platform_error | retry |
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.