Przejdź do treści
ErmisAI

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 pathAuthPlan gatePurpose
GET /api/integrations/cms/storiesx-ermis-api-keyPro, Business Pro, EnterpriseApproved stories for CMS ingestion
GET /api/integrations/wordpress/storiesx-ermis-api-keyPro, Business Pro, EnterpriseIdentical contract, WordPress surface label
POST /api/integrations/wordpress/disconnectx-ermis-api-keynoneRevoke the calling key and its WordPress connection
GET /api/openapi/public-integrationsnonenoneThe 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

Właściwość

Typ

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:

  1. isStoryWorkflowDeliverable — the draft has a real headline and body and its editorial state is published (src/lib/platform/feed/publishability.ts:28-33). published is only ever set by a human approving in editorial review.
  2. The optional category filter.
  3. 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.

FieldTypeNotes
storyIdstringContent-derived. Not globally unique — the same id can exist in another tenant.
slugstringLiterally storyId. Not an SEO slug; the document says so itself.
titlestringThe approved draft headline, in the workspace's content language.
excerptstringWhitespace-collapsed body truncated at 260 characters plus .... Generated, not authored.
contentstringThe approved draft body as stored.
categorystringLocalized category string.
tagsstring[]Set during editorial compose.
readingTimeMinutesinteger | nullNull when unavailable.
confidencenumberSchema allows 0–100; real values are clamped to 40–99 (src/lib/services/rss-aggregation.ts:2819).
breakingboolean
updatedAtstringISO 8601. The sort key.
publishedAtstringISO 8601, set at editorial approval. Never null in the export.
sourceCountinteger
sources{ source, contribution }[]See below.
permalinkstringRelative 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.

StatusBodyWhen
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 in items. 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 a CMS or WORDPRESS tile.
  • Delivery-log writes are best effort. A failure is written to stderr and the export still returns 200.
  • Poll conservatively. Ordering is by updatedAt with no cursor, so a small limit combined with an infrequent poll can miss stories permanently. Track storyId on 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, with Cache-Control: public, max-age=300 set 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.

Na tej stronie