WordPress connector handshake
The OAuth handshake, what the connector writes, plan gating, and the failure modes.
ErmisAI ships the server half of a WordPress integration: an OAuth-style browser handshake that ends with a WordPress site holding a managed tenant API key, plus the pull endpoint that key authenticates against.
There is no PHP plugin in this repository and no download link anywhere in the product. The
approval screen and the integrations card both tell the user to "Install the ErmisAI Newsroom
Connector in WordPress", with no way to obtain it. docs/ai-newsroom-product-spec.md:808-812
states it directly: "there is currently no PHP plugin source checked into the repository …
The plugin is the external client of this flow and must be built/published separately." Earlier
docs citing wordpress/hermesai-newsroom-plugin/ or wordpress/ermisai-newsroom-plugin describe
paths that do not exist (docs/implementation/spec-alignment.md:94-97).
ErmisAI never writes to WordPress. The data flow is a pull, and the product's own copy says so —
app.pages.wordpressConnect.notes.noAutoPublish: "ErmisAI will not publish into WordPress by
itself. The plugin only pulls published ErmisAI stories and creates or updates WordPress posts."
Post creation, post status, dedupe, and category mapping are entirely the connector's business.
Route inventory
| Route | Auth | Plan gate | Public in proxy |
|---|---|---|---|
GET /app/integrations/wordpress/connect | Clerk session | canAccessWordPressForPlan (plan only) | no |
POST /api/integrations/wordpress/connect/authorize | Clerk session + integrations surface | canAccessWordPressForSubscription | no |
POST /api/integrations/wordpress/connect/exchange | one-time code | canAccessWordPressForSubscription | yes |
GET/DELETE /api/integrations/wordpress/connections | Clerk session + integrations surface | canAccessWordPressForSubscription | no |
POST /api/integrations/wordpress/disconnect | raw key in x-ermis-api-key | none | yes |
GET /api/integrations/wordpress/stories | raw key in x-ermis-api-key | canAccessWordPressForSubscription | yes |
The three sessionless routes are listed in the proxy allowlist at src/proxy.ts:161-163 so Clerk
does not intercept them; each authenticates itself inside the handler. All three are IP rate-limited
under the integration:m2m key — 60 requests / 60 s (src/lib/api/rate-limit.ts:55).
Only wordpress/stories and wordpress/disconnect appear in the public OpenAPI document
(src/lib/openapi/public-integration-api.ts). The connect, authorize, exchange, and connections
routes are deliberately absent — they are the handshake, not the published API. See
Public integration API for the pull contract.
The handshake
The connector redirects a browser to the approval page
/app/integrations/wordpress/connect?siteUrl=…&returnUrl=…&state=…&pluginVersion=…&wordpressVersion=…&siteName=…
There is no ErmisAI-side button that starts this. The flow begins in WordPress; the approval page
has no inbound link from the tenant app and its metadata is noindex
(src/app/(tenant)/app/integrations/wordpress/connect/page.tsx:23-26).
An unauthenticated visitor is redirected to /sign-in?redirect_url=<the connect URL with its full query> (page.tsx:71-76), so the query survives sign-in.
ErmisAI validates the request and renders the approval screen
Parsing is parseWordPressConnectRequest (src/lib/integrations/wordpress-connect.ts:105-139).
The screen shows five fields — "WordPress site", "ErmisAI workspace", "Connector version",
"WordPress version", "Return URL" — the two notes, and two actions: "Connect site" and
"Cancel".
No WordPress user identity is captured, and there is nothing to save.
The owner presses "Connect site"
The button submits an HTML form (not JSON) to POST /api/integrations/wordpress/connect/authorize;
the handler reads Object.fromEntries(await request.formData())
(.../connect/authorize/route.ts:61). It re-runs the same validation, checks the Clerk session, the
integrations tenant surface, and the standing-aware plan gate, then creates a connect session and
answers 303 to returnUrl with ermisai_status=approved&code=<code>&state=<state>
(authorize/route.ts:110-117).
The connector exchanges the code for a key
POST /api/integrations/wordpress/connect/exchange with
{"code": …, "state": …, "siteUrl": …}. The session is consumed (single use), the plan gate is
re-checked, a managed API key is minted, and the connection row is written. The response body is the
only time the raw key is ever returned (exchange/route.ts:94-103).
The connector pulls stories with that key
GET /api/integrations/wordpress/stories with header x-ermis-api-key: <key>. Contract, query
params, caveats: Public integration API.
Connect URL contract
| Param | Required | Constraint |
|---|---|---|
siteUrl | yes | Absolute http/https URL, no embedded credentials. HTTPS required in production unless the host is loopback. Only the origin is retained — path and query are discarded (wordpress-connect.ts:130). |
returnUrl | yes | Same constraints, plus: origin must equal siteUrl's origin, and the pathname must start with /wp-admin/ (wordpress-connect.ts:117-127). Retained in full. |
state | yes | Trimmed, 16-200 characters (wordpress-connect.ts:5). Echoed back on every outcome. |
siteName | no | Trimmed, max 120 characters; anything failing that becomes '' (wordpress-connect.ts:131-133). |
pluginVersion | no | Trimmed, max 40 characters; empty becomes null. |
wordpressVersion | no | Trimmed, max 40 characters; empty becomes null. |
Hosts treated as loopback: localhost, *.localhost, 127.*, ::1
(wordpress-connect.ts:21-36). They are the only exemption from the production HTTPS rule, so a
plain-HTTP http://localhost site passes URL validation even when NODE_ENV=production. Every other
host must be HTTPS there.
Return-URL outcomes
Every outcome routes back through buildWordPressConnectReturnUrl
(wordpress-connect.ts:159-175), which sets or deletes query params on the connector's returnUrl.
| Outcome | Params added |
|---|---|
| Approved | ermisai_status=approved, code, state |
Cancelled (the "Cancel" link) | ermisai_status=cancelled, ermisai_message=Connection cancelled., state |
| Error after the request parsed | ermisai_status=error, ermisai_code, ermisai_message, state |
Error codes carried in ermisai_code (authorize/route.ts:22-46):
| Code | Message sent back | Cause |
|---|---|---|
unauthorized | Authentication required. | No Clerk session |
forbidden | You do not have permission to authorize WordPress. | Not the workspace owner |
wordpress_integration_unavailable | WordPress integration requires the Pro or Enterprise plan. | Plan or standing gate |
invalid_wordpress_connect_request | Invalid WordPress connection request. | Schema, origin, or /wp-admin/ check failed |
wordpress_connect_authorize_failed | Failed to authorize WordPress connection. | Anything else, including URL-shape failures that throw invalid_wordpress_connect_url |
If the form body itself fails to parse there is no returnUrl to redirect to, so the route answers
with a JSON error envelope instead (authorize/route.ts:136).
Connect sessions
createWordPressConnectSession (src/lib/platform/integrations/keys-and-wordpress.ts:704-743):
code=randomBytes(24).toString('hex')— 48 hex characters.- TTL =
max(5, ttlMinutes ?? 15)minutes. No caller passesttlMinutes, so 15 minutes in practice. - The record holds
ownerUserId,tenantScopeId,siteUrl,siteName,publicationName,pluginVersion,wordpressVersion,returnUrl,state,createdAt,expiresAt. - Storage is a single global
platform_statearray, keyplatform:integrations-wordpress-connect-sessions:global(src/lib/platform/shared/runtime-state.ts:30-32). There is no Postgres table for it. Expired entries are pruned on every write.
That key is platform-wide, not per-tenant, and every create is a read-modify-write of the whole array. It is a hot key by construction. Keep an eye on it if connect volume ever grows.
consumeWordPressConnectSession (keys-and-wordpress.ts:745-778) matches on code and state
and siteUrl (origin-normalised), removes the matched record, and returns it. A second call with
the same code returns null and the route answers 410
"WordPress connect session is missing or expired.", code wordpress_connect_session_missing
(exchange/route.ts:47-51).
The exchange payload schema (wordpress-connect.ts:83-87) requires code 24-200 characters and
state 16-200 characters, and normalises siteUrl to its origin before comparison.
What the exchange returns
Prop
Type
Key label construction is buildWordPressManagedKeyLabel (wordpress-connect.ts:177-189);
publication-name resolution is resolveWordPressConnectPublicationName
(wordpress-connect.ts:92-103).
What gets written
One row in tenant_wordpress_connections (src/lib/db/schema.ts:716-748), with a platform_state
fallback under platform:integrations-wordpress-connections:<tenantScopeId>:
| Column | Note |
|---|---|
id | UUID |
tenant_scope_id | org:<clerkOrgId> or user:<clerkUserId> |
owner_user_id | The Clerk user who approved |
site_url | Origin. Unique per (tenant_scope_id, site_url) |
site_name, publication_name | Default '' |
key_id, key_label | key_id carries a unique index — one connection per key |
plugin_version, wordpress_version | Nullable |
connected_at, last_used_at, created_at, updated_at | last_used_at is null until the key is first used |
Plus one row in tenant_integration_api_keys for the minted key. last_used_at on both is stamped
by resolveApiKeyAccess on every successful key resolution
(keys-and-wordpress.ts:466-535), which is what the console renders as "Last activity".
If registerWordPressConnection throws, the just-minted key is revoked before the error propagates,
so a transient failure cannot leave a live credential with no connection record
(exchange/route.ts:74-92).
Key ↔ connection coupling
The two lifecycles are welded together on purpose (module docstring,
keys-and-wordpress.ts:1-5):
- Revoking a key deletes its connection.
revokeApiKey(:397),revokeApiKeyByRawKey(:537), andrevokeTenantApiKeyByIdInternal(:216) all calldeleteWordPressConnectionByKeyId. - Disconnecting a connection revokes its key.
disconnectWordPressConnection(:674-702) revokes first, then deletes the row if no key was found to revoke. - Reconnecting the same site replaces the key silently.
registerWordPressConnection(:616-658) looks up the existing connection bysiteUrl; if the incomingkeyIddiffers it revokes the old key withnotify: falseand upserts over the same connection row. The user gets no"API key revoked"notification for that path.
Revoking an API key from the API Keys card takes the WordPress site connected with it offline, and
nothing in that card says so. The reverse also holds: "Revoke access" on a WordPress connection
revokes the key, so any other consumer using that same key stops working too.
Gating
Role. The integrations tenant surface maps to canManageIntegrations, which is
hasAdminBypass(appRole) || tenantRole === 'owner'
(src/lib/auth/newsroom-roles.ts:103-105, :200-218), and hasAdminBypass is only
appRole === 'super_admin' (:51-53). Only the workspace owner — or a platform super_admin —
can approve a connection or manage connections.
The denial string on the approval screen is wrong. app.pages.wordpressConnect.accessRestricted.description
reads "Only owners and admins can connect WordPress sites." Tenant admin cannot; the gate is
owner-only. Do not repeat that sentence in your own connector copy.
Plan. wordpressDelivery is a capability of individual_pro, business_pro, enterprise, and
the hidden internal_unlimited staff plan. Free, Plus, and Business Plus do not have it.
Standing. canAccessWordPressForSubscription runs the plan through
resolveEffectivePlanIdForStanding (src/lib/billing/catalog.ts:476-503), which collapses a
past_due or canceled subscription to individual_free. Every WordPress API call therefore fails
for a Pro workspace that has stopped paying.
The approval page uses the plan-only helper canAccessWordPressForPlan(subscription.plan)
(connect/page.tsx:101), while authorize, exchange, and connections use the standing-aware
canAccessWordPressForSubscription. A past_due Pro workspace sees the approval screen, presses
"Connect site", and is bounced back to wp-admin with
ermisai_code=wordpress_integration_unavailable. Handle that redirect explicitly in the connector.
A downgrade does not disconnect anything. The connection row and its key stay in place; only the
calls 403. And because DELETE /api/integrations/wordpress/connections is itself behind the
standing-aware gate, a downgraded tenant cannot revoke its own connection from the console — the
plugin-side disconnect route, which has no plan gate, is the only route still open to it.
Disconnect: two different routes
DELETE /api/integrations/wordpress/connections — the owner-facing action behind the
"Revoke access" button. Session-authed, plan-gated, body {"connectionId": "…"}. An unknown id
returns 404 "WordPress connection not found". Success returns the remaining connections
(connections/route.ts:39-78).
POST /api/integrations/wordpress/disconnect — the connector's self-disconnect. Sessionless, no
role gate, no plan gate. Auth is the raw key in x-ermis-api-key; an empty or absent header returns
401 "Tenant API key is required" with code wordpress_disconnect_missing_key. Otherwise it
always returns 200 {"ok": true, "revoked": <boolean>} — an unknown or already-revoked key yields
revoked: false, so the call is idempotent (disconnect/route.ts:21-49).
The OpenAPI document shows a 500 example carrying code: "wordpress_disconnect_failed"
(src/lib/openapi/public-integration-api.ts:307-318). The route builds that response with
createInternalRouteErrorResponse, which emits only error and message and no code field
(src/lib/utils/error-format.ts:313-328). Do not branch on that code.
The connections console
/app/settings?section=integrations → "WordPress connections". Per connection it renders the site
name or URL, the raw URL in mono, then "Workspace", "Key", "Connected", "Last activity"
("never" until first key use), and version chips prefixed "Connector" and "WP"
(src/components/features/integrations/WordPressConnectionsCard.tsx:58-111). The only action is
"Revoke access" / "Revoking...". Empty state: "No WordPress sites connected yet."
GET /api/integrations/wordpress/connections returns { items: [...] } with id, siteUrl,
siteName, publicationName, keyId, keyLabel, pluginVersion, wordpressVersion,
connectedAt, lastUsedAt, updatedAt (src/lib/api/integrations.ts:13-25). No secret material
is exposed.
When the plan does not allow WordPress, the card is replaced by an alert with hardcoded English,
not i18n: "WordPress delivery is unavailable" / "This workspace plan does not include managed WordPress connections." (src/components/features/integrations/IntegrationsWorkspace.tsx:314-318).
Failure modes worth knowing
-
Onboarding blocks the handshake mid-flight. The approval page calls
requireCompletedNewsroomSetup(connect/page.tsx:81), which redirects to/app/onboardingand loses the connect query string. A user who signs up from the plugin redirect has to finish onboarding and then restart the flow from WordPress. -
APP_URLis load-bearing for the exchange, and it hard-fails.resolveAppOriginis called while building the exchange response (exchange/route.ts:95). With neitherAPP_URLnorNEXT_PUBLIC_APP_URLset, the production branch throws aRouteError503missing_app_origin(src/lib/auth/clerk-session.ts:150-167) — after the session has been consumed, the key minted, and the connection registered. The connector never receives the key and must restart the handshake; the orphan key is cleaned up by the reconnect, which revokes the previous key for the same site. The launch-readiness entry forAPP_URL(src/lib/platform/launch-readiness.ts:90-96) describes this as callbacks resolving to the deployment origin; in production it is a 503, not a fallback. -
The code is single-use, including on a retry. If the connector's exchange request times out after ErmisAI has consumed the session, retrying with the same code returns 410. The connector must treat 410 as "start over", not "retry".
-
stateis echoed but never bound to a browser session by ErmisAI. ErmisAI stores it and requires it to match at exchange time; CSRF protection for the WordPress admin side is the connector's responsibility. -
Redirects are 303, from a form POST. Anything expecting a JSON response from
authorizewill break. It is a browser endpoint. -
Connect sessions do not survive a
platform_statewipe and have no Postgres table. A deployment that clears runtime state mid-handshake invalidates every in-flight code. -
A pull is logged as a fetch, not a publication. One delivery-log record per request with
method: 'wordpress', not one per story, and the export is subject to the tenant's daily delivery cap — a busy polling loop consumes the newsroom's quota. See Public integration API.
Building a connector
Everything below is the external client's job; none of it exists in this repository.
- Redirect to the approval page with a fresh
state, yoursiteUrl, and areturnUrlunder/wp-admin/on the same origin. - Handle four return states:
approved,cancelled,error(readermisai_codeandermisai_message), and no callback at all. - Exchange the code immediately; store
apiKeyandapiBaseUrlas WordPress secrets. The key is never retrievable again. - Poll
GET {apiBaseUrl}/api/integrations/wordpress/storieswithx-ermis-api-key. Dedupe bystoryIdyourself — the export does no dedupe, andslugis literally thestoryId. - Treat a 403 as a plan or standing problem, not a bad key; treat a 401 as a revoked key.
- Call
POST /api/integrations/wordpress/disconnectwith the key when the site uninstalls. It is idempotent.
Public integration API
The pull contract the connector actually consumes: key auth, query params, response shape, delivery-cap caveats.
API keys, webhooks and WordPress
The owner-facing console, in product terms.
API error and status reference
Every code and status this handshake can return.
Tenant scoping and isolation
What org: and user: scope ids mean and how they gate the key.
