Public integration API
The public API surface, authentication, and the OpenAPI document.
ErmisAI publishes a small, deliberately narrow API for external systems: three endpoints, one auth mechanism, one OpenAPI document. It is a pull surface. ErmisAI does not push stories into a CMS; a consumer polls for approved stories and does whatever it wants with them.
Everything else under /api/integrations/* is a session-authenticated console API for the owner-only integrations screen, and is not part of the published surface.
The surface at a glance
| Method and path | Auth | Plan gate | Purpose |
|---|---|---|---|
GET /api/integrations/cms/stories | x-ermis-api-key | Pro, Business Pro, Enterprise | Approved stories for CMS ingestion |
GET /api/integrations/wordpress/stories | x-ermis-api-key | Pro, Business Pro, Enterprise | Identical contract, WordPress surface label |
POST /api/integrations/wordpress/disconnect | x-ermis-api-key | none | Revoke the calling key and its WordPress connection |
GET /api/openapi/public-integrations | none | none | The OpenAPI 3.0.3 document |
All four are allow-listed past Clerk in src/proxy.ts:161-169, so no session cookie is involved. The two list routes are thin wrappers over one shared handler (src/app/api/integrations/cms/stories/route.ts:4-7 and src/app/api/integrations/wordpress/stories/route.ts:4-7, both calling handlePublicCmsStoryExportRequest in src/lib/platform/integrations/public-cms-story-export.ts). The only difference between them is the surface label — 'cms' or 'wordpress' — written to the tenant's delivery log.
/api/openapi/public-integrations is one of exactly two routes allow-listed as anonymous in tests/api-route-auth-guard.vitest.ts:14-20; the other is /api/health.
Authentication
The key header
Every request carries the raw tenant API key in the x-ermis-api-key header. There is no bearer scheme, no OAuth token, and no signature on inbound requests.
curl -sS 'https://ermisai.com/api/integrations/cms/stories?limit=20' \
-H "x-ermis-api-key: $ERMIS_API_KEY"The handler trims the header, rejects an empty value with 401 Tenant API key is required, then resolves it by SHA-256 hash to { keyId, userId, tenantScopeId } (public-cms-story-export.ts:29-40, resolveApiKeyAccess in src/lib/platform/integrations/keys-and-wordpress.ts). An unresolvable or revoked key returns 401 Invalid tenant API key.
The tenant scope resolved from the key is the only tenant context the request has. There is no tenant id parameter anywhere in the API.
Key format and lifecycle
Keys are ermis_ followed by 48 hex characters — `ermis_${randomBytes(24).toString('hex')}` (keys-and-wordpress.ts:350). There is no live/test segment; docs/api/public-integration-api.md still shows an ermis_live_… form and is wrong on that point.
Only a SHA-256 hex hash is stored (keys-and-wordpress.ts:32-34), alongside a masked display value ermis_••••••••<last4> (:36-38). A key's raw value is revealed once and never again — either at creation in the integrations screen, or in the response to the WordPress connect exchange. It cannot be recovered afterwards, and there is no rotate action: only create and revoke.
Two coupling rules matter to an integrator:
- Revoking a key deletes the WordPress connection bound to it, and disconnecting a connection revokes its key.
- Reconnecting the same WordPress site mints a new key and silently revokes the previous one.
Keys are tenant-scope-wide, not per-user and not per-integration. Any key for a workspace reads every deliverable story in that workspace. If you run several consumers, mint a key per consumer so one revocation does not take the others down — but be aware that revoking a key that was minted by the WordPress handshake also removes that site's connection.
Plan gate
Both list endpoints call canAccessWordPressForSubscription (src/lib/billing/catalog.ts:499-503). Failure returns 403 with the literal message CMS story export requires Pro or Enterprise (public-cms-story-export.ts:45-47). The capability behind it is wordpressDelivery, true on individual_pro, business_pro, and enterprise.
The gate is standing-aware: resolveEffectivePlanIdForStanding (catalog.ts:476-483) reverts a past_due or canceled subscription to individual_free entitlements, so a lapsed Pro workspace gets the same 403 as a Free one, with no separate billing error.
POST /api/integrations/wordpress/disconnect has no plan gate. A consumer can always disconnect itself.
Rate limit
All three endpoints run enforceEdgeRateLimit on the key integration:m2m before any auth work: 60 requests per 60 seconds per IP (src/lib/api/rate-limit.ts:54). No user id is passed on these routes, so the 120/60s per-user limiter never applies.
Exceeding it returns 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers, and a body of { error, code: "rate_limited", scope, retryAfterSeconds }. This response is not in the OpenAPI document — handle it anyway.
Rate limiting is backed by Upstash Redis and fails open: when Redis is absent or unreachable the limiter allows the request (rate-limit.ts:203-213). Do not treat the cap as a guarantee in either direction.
Listing approved stories
GET /api/integrations/cms/stories and GET /api/integrations/wordpress/stories return the same body.
Query parameters
Ominaisuus
Tyyppi
There is no pagination, no cursor, no offset, and no since parameter (public-cms-story-export.ts:104-127).
What gets exported
The handler reads the tenant's own feed through listTenantStoryFeedItems, then applies three filters in order:
isStoryWorkflowDeliverable— the draft has a real headline and body and its editorial state ispublished(src/lib/platform/feed/publishability.ts:28-33).publishedis only ever set by a human approving in editorial review.- The optional
categoryfilter. slice(0, limit).
After slicing, each item is loaded in full and dropped if its draft has no publishedAt (public-cms-story-export.ts:140-142). That drop happens after the slice, so count can be lower than limit even when more deliverable stories exist.
Ordering is newest-updatedAt-first, inherited from the feed (src/lib/platform/feed/feed-lifecycle.ts:38-42). It is not publication order. Editing an approved story moves it back to the front of the window.
Response shape
{
"generatedAt": "2026-06-26T10:00:00.000Z",
"count": 1,
"items": []
}items is an array of PublicCmsStory objects, described below. The response is validated against publicCmsStoryExportResponseSchema before it is sent (src/lib/contracts/public-integration-api.ts:36-40), so a contract violation surfaces as a 500 rather than a malformed body.
| Field | Type | Notes |
|---|---|---|
storyId | string | Content-derived. Not globally unique — the same id can exist in another tenant. |
slug | string | Literally storyId. Not an SEO slug; the document says so itself. |
title | string | The approved draft headline, in the workspace's content language. |
excerpt | string | Whitespace-collapsed body truncated at 260 characters plus .... Generated, not authored. |
content | string | The approved draft body as stored. |
category | string | Localized category string. |
tags | string[] | Set during editorial compose. |
readingTimeMinutes | integer | null | Null when unavailable. |
confidence | number | Schema allows 0–100; real values are clamped to 40–99 (src/lib/services/rss-aggregation.ts:2819). |
breaking | boolean | |
updatedAt | string | ISO 8601. The sort key. |
publishedAt | string | ISO 8601, set at editorial approval. Never null in the export. |
sourceCount | integer | |
sources | { source, contribution }[] | See below. |
permalink | string | Relative app path /app/stories/<storyId>, not a public URL. |
sources[].contribution is generated boilerplate, not editorial metadata. Every entry is the template `Referenced in editorial draft for ${eventSummary || headline}` (src/lib/platform/local-platform-data.ts:2164-2171). The OpenAPI example ("Referenced in editorial draft for international coverage") makes it look curated. Do not render it as a per-source explanation.
Not exposed at all: the sensitive flag, conflicts, claim provenance, editorial state, and pipeline phase. If your integration needs any of those, this API cannot supply them today.
Disconnecting
curl -sS -X POST 'https://ermisai.com/api/integrations/wordpress/disconnect' \
-H "x-ermis-api-key: $ERMIS_API_KEY"The route reads the key header, calls revokeApiKeyByRawKey, and returns { ok: true, revoked: <boolean> } (src/app/api/integrations/wordpress/disconnect/route.ts:21-49). It is idempotent: an unknown or already-revoked key returns 200 with revoked: false. There is no body, no role gate, and no plan gate. Revoking the key also deletes the WordPress connection bound to it.
This is the connector's own self-disconnect. The owner-facing equivalent is DELETE /api/integrations/wordpress/connections, which requires a Clerk session and is not part of the public surface.
Errors
Error bodies are not uniform across the three endpoints, because two different helpers produce them.
| Status | Body | When |
|---|---|---|
401 | {"error":"Tenant API key is required"} | Header missing or empty (list endpoints) |
401 | {"error":"Invalid tenant API key"} | Key does not resolve, or was revoked |
401 | {"error":…,"message":…,"code":"wordpress_disconnect_missing_key"} | Header missing on disconnect |
403 | {"error":"CMS story export requires Pro or Enterprise"} | Plan or standing gate |
429 | {"error":…,"code":"rate_limited","scope":…,"retryAfterSeconds":…} | integration:m2m limit |
500 | {"error":"Failed to load stories for export","message":…} | Any throw inside the export |
500 | {"error":"Failed to disconnect the WordPress site.","message":…,"code":"wordpress_disconnect_failed"} | Any throw inside disconnect |
The list endpoints' 401 and 403 responses carry only an error key — no message, no code (public-cms-story-export.ts:98-102). Parse defensively.
What a pull costs the tenant
These are the facts nothing in the product UI or the OpenAPI document tells a consumer, and they are the ones that cause support tickets.
The export is served from the same delivery-capped feed the in-app workspace uses, and calling it mutates delivery state. listTenantStoryFeedItems releases stories against the tenant's daily cap and writes method: 'feed' delivery-log records as a side effect (public-cms-story-export.ts:112). A busy polling loop spends the workspace's daily delivery quota on the workspace's behalf.
Consequences worth designing around:
- Over-cap stories are invisible. Once the tenant hits
maxStoryDeliveriesPerDay— 20 on Free, 100 on Plus and Business Plus, 500 on Pro and Business Pro, unlimited on Enterprise — further deliverable stories move to a queued set and never appear initems. The export cannot see them and reports no indication that they exist. - The quota resets at 00:00 UTC, not in the tenant's timezone.
- One pull logs one delivery record, not one per story, and it records a fetch rather than a publication. The in-code comment is explicit that true publication would need an acknowledgement from the CMS side (
public-cms-story-export.ts:173-176). That record shows up in the tenant's Usage screen as aCMSorWORDPRESStile. - Delivery-log writes are best effort. A failure is written to stderr and the export still returns 200.
- Poll conservatively. Ordering is by
updatedAtwith no cursor, so a smalllimitcombined with an infrequent poll can miss stories permanently. TrackstoryIdon your side and treat re-fetches as updates.
The OpenAPI document
The document is hand-built as a plain object in src/lib/openapi/public-integration-api.ts:324-374 — it is not generated from the zod contracts, so the two can drift.
- Served live at
GET /api/openapi/public-integrations, withCache-Control: public, max-age=300set by the handler (src/app/api/openapi/public-integrations/route.ts:3-9). - Committed at
docs/api/public-integration-api.openapi.json. - Regenerate with
pnpm api:openapi(scripts/api/generate-public-integration-openapi.ts).
curl -sS https://ermisai.com/api/openapi/public-integrations | jq '.info, (.paths | keys)'Its shape: openapi: 3.0.3, info.version from publicIntegrationApiVersion (1.0.0, src/lib/contracts/public-integration-api.ts:3), one server (https://ermisai.com), one tag (CMS Export), three paths, one security scheme (TenantApiKey, apiKey in header, name x-ermis-api-key), and five schemas: PublicCmsStorySource, PublicCmsStory, PublicCmsStoryExportResponse, PublicWordPressDisconnectResponse, PublicIntegrationErrorResponse.
Documented responses are 200, 401, 403, and 500 on the list operations, and 200, 401, and 500 on disconnect. The 429 is missing from all three.
What is deliberately absent
These routes exist and are not in the document, because they are session-authenticated console APIs rather than integration endpoints:
POST /api/integrations/wordpress/connect/authorize, POST /api/integrations/wordpress/connect/exchange, GET/DELETE /api/integrations/wordpress/connections, GET/POST/DELETE /api/integrations/api-keys, GET/PUT /api/integrations/webhook, POST /api/integrations/webhook/test, and GET /api/integrations/webhook-logs.
The connect exchange is a genuine exception: it is sessionless and machine-callable, but it is a single-use step in a browser-initiated handshake rather than a callable API. See WordPress connector handshake.
Extending the surface
If you add an endpoint here, four things need to happen together:
Write the handler with its own auth gate. tests/api-route-auth-guard.vitest.ts scans every route.ts under src/app/api for a recognised gate token; x-ermis-api-key and resolveApiKeyAccess are both recognised. A route with neither, and not in the two-entry allowlist, fails CI.
Add the path to isPublicRoute in src/proxy.ts if it must be sessionless. Without it, Clerk's auth.protect runs first and an anonymous machine request is redirected to sign-in instead of reaching your handler.
Apply enforceEdgeRateLimit with the integration:m2m key, or add a new limiter key to both perIpConfig and perUserConfig in src/lib/api/rate-limit.ts — the two records are typed over the same union, so omitting one will not compile.
Extend src/lib/openapi/public-integration-api.ts and run pnpm api:openapi to refresh the committed JSON. Nothing regenerates it automatically and no test compares the document to the routes, so a stale document is a silent defect.
Outbound webhooks
The push direction: signatures, headers, the two event types, and why there is no retry.
WordPress connector handshake
How a key is minted from WordPress, and why no plugin ships here.
API error and status reference
The shared error envelope and every domain error code.
API keys, webhooks and WordPress
The owner-only console where keys are minted and revoked.
Plan and quota reference
Delivery caps, capability flags, and what a lapsed subscription reverts.
Getting approved stories out
How the export fits alongside the in-app feed and webhooks.
