{
  "openapi": "3.1.0",
  "info": {
    "title": "Empyre Public API",
    "version": "1.0.0",
    "summary": "The endpoints third parties and AI agents are meant to call.",
    "description": "Empyre builds and operates software companies with a workforce of AI executives. Four products share one account: Empyre (the business builder), Relay (OAuth and identity for AI agents), Vault (secret storage and signing) and Ledger (double-entry accounting with an autonomous CFO).\n\nThis document covers only the surface that is intended for third parties and AI agents. The platform's own authenticated dashboard API is not part of it and is not a stable contract.\n\nAn agent that wants to OPERATE an Empyre account should use the Model Context Protocol connector rather than this API: point any MCP client at https://www.empyre.dev/mcp and sign in. Relay, Vault and Ledger each expose their own connector on their own host. See https://www.empyre.dev/llms.txt for when to reach for each product.",
    "termsOfService": "https://www.empyre.dev/terms",
    "contact": {
      "name": "Empyre",
      "email": "info@empyre.dev",
      "url": "https://www.empyre.dev/contact"
    },
    "license": { "name": "Proprietary", "url": "https://www.empyre.dev/terms" }
  },
  "servers": [
    { "url": "https://api.empyre.dev", "description": "Production" }
  ],
  "externalDocs": {
    "description": "Documentation",
    "url": "https://www.empyre.dev/docs"
  },
  "tags": [
    { "name": "relay-oauth", "description": "Relay: OAuth 2.1 for AI agents acting on a person's behalf. Hosted consent, S256 PKCE, one-time codes, refresh rotation, fail-closed revocation and introspection." },
    { "name": "mcp", "description": "Model Context Protocol servers. One per product, on that product's own host." },
    { "name": "company-content", "description": "The public read API a company built by Empyre serves its own blog and support surface from." },
    { "name": "platform", "description": "Platform status." }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": ["platform"],
        "operationId": "getHealth",
        "summary": "Platform health",
        "description": "Returns whether the platform API is serving. Unauthenticated. Use it to decide whether a failure is yours or ours before retrying.",
        "responses": {
          "200": {
            "description": "The API is serving.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": { "type": "string", "examples": ["ok"] }
                  },
                  "required": ["status"]
                }
              }
            }
          }
        }
      }
    },
    "/relay/oauth/authorize": {
      "get": {
        "tags": ["relay-oauth"],
        "operationId": "relayAuthorize",
        "summary": "Begin an authorization",
        "description": "Send the person's browser here to start an authorization. Relay renders the hosted consent screen and, on approval, redirects back to redirect_uri with a one-time code. PKCE is mandatory: code_challenge_method must be S256. This is a browser redirect, not an API call an agent makes directly.",
        "parameters": [
          { "name": "client_id", "in": "query", "required": true, "description": "The client id issued when the application was registered with Relay.", "schema": { "type": "string" } },
          { "name": "redirect_uri", "in": "query", "required": true, "description": "Must match a redirect URI registered on the client exactly, byte for byte.", "schema": { "type": "string", "format": "uri" } },
          { "name": "scope", "in": "query", "required": false, "description": "Space-separated scopes. Defaults to \"openid profile\".", "schema": { "type": "string", "default": "openid profile" } },
          { "name": "state", "in": "query", "required": false, "description": "Opaque value returned unchanged on the redirect. Use it to defend against CSRF.", "schema": { "type": "string" } },
          { "name": "code_challenge", "in": "query", "required": false, "description": "Base64url SHA-256 of the code verifier, with padding removed.", "schema": { "type": "string" } },
          { "name": "code_challenge_method", "in": "query", "required": false, "description": "S256 only. `plain` is refused.", "schema": { "type": "string", "enum": ["S256"] } }
        ],
        "responses": {
          "302": { "description": "Redirect to the hosted consent screen, or back to redirect_uri with an error." },
          "400": { "description": "The request is malformed or the redirect URI does not match the registered client.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      }
    },
    "/relay/oauth/token": {
      "post": {
        "tags": ["relay-oauth"],
        "operationId": "relayToken",
        "summary": "Exchange a code or refresh a token",
        "description": "Exchange a one-time authorization code for an access token, or present a refresh token for a new pair. Accepts form-encoded (RFC 6749) and JSON bodies alike. Refresh tokens rotate: the response's refresh_token replaces the one presented, and replaying a spent refresh token revokes the whole family.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/TokenRequest" } },
            "application/json": { "schema": { "$ref": "#/components/schemas/TokenRequest" } }
          }
        },
        "responses": {
          "200": { "description": "A new access token, and a rotated refresh token when one was requested.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TokenResponse" } } } },
          "400": { "description": "The grant was refused.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "401": { "description": "The client could not be authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      }
    },
    "/relay/oauth/userinfo": {
      "get": {
        "tags": ["relay-oauth"],
        "operationId": "relayUserinfo",
        "summary": "Read the consented profile claims",
        "description": "Returns the claims the access token's scopes allow, and nothing else: `profile` yields name and preferred_username, `email` yields email and email_verified. A token whose subject is an agent rather than a person returns `sub` alone. Fails closed with 401 on a revoked or expired token.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "The claims permitted by the token's scopes.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserInfo" } } } },
          "401": { "description": "The token is missing, expired or revoked.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      }
    },
    "/relay/oauth/introspect": {
      "post": {
        "tags": ["relay-oauth"],
        "operationId": "relayIntrospect",
        "summary": "Ask whether a token is still valid",
        "description": "RFC 7662 introspection. Answers active=false for a token that is unknown, expired or revoked, and never explains which. Fails closed: an introspection that cannot be completed reports the token as inactive rather than assuming it is good.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "properties": { "token": { "type": "string", "description": "The access or refresh token to check." } }, "required": ["token"] } } }
        },
        "responses": {
          "200": { "description": "Whether the token is active, and its metadata when it is.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Introspection" } } } }
        }
      }
    },
    "/relay/oauth/revoke": {
      "post": {
        "tags": ["relay-oauth"],
        "operationId": "relayRevoke",
        "summary": "Revoke a token",
        "description": "RFC 7009 revocation. Revoking a refresh token revokes the access tokens issued from it. Answers 200 whether or not the token existed, so a caller cannot use this endpoint to discover valid tokens.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "properties": { "token": { "type": "string", "description": "The access or refresh token to revoke." } }, "required": ["token"] } } }
        },
        "responses": { "200": { "description": "The token is no longer usable." } }
      }
    },
    "/relay/oauth/clients/resolve": {
      "get": {
        "tags": ["relay-oauth"],
        "operationId": "relayResolveClientByDomain",
        "summary": "Find the Relay client registered for a domain",
        "description": "Given a verified domain, returns the public client registration for it: client id, display name and logo. Use it to show a person which application is asking for access. Never returns a client secret.",
        "parameters": [
          { "name": "domain", "in": "query", "required": true, "description": "A hostname, without scheme or path.", "schema": { "type": "string", "examples": ["example.com"] } }
        ],
        "responses": {
          "200": { "description": "The public registration for that domain.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicClient" } } } },
          "404": { "description": "No verified client is registered for that domain.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/mcp/products": {
      "get": {
        "tags": ["mcp"],
        "operationId": "listMcpProducts",
        "summary": "List the MCP servers",
        "description": "Returns every product that exposes a Model Context Protocol server and the endpoint for each. Each product is a separate issuer with its own tools, scopes and tokens; a token issued for one is not accepted by another.",
        "responses": {
          "200": {
            "description": "The available MCP servers.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/components/schemas/McpProduct" } } }, "required": ["products"] } } }
          }
        }
      }
    },
    "/mcp/{product_id}": {
      "post": {
        "tags": ["mcp"],
        "operationId": "callMcpServer",
        "summary": "Model Context Protocol endpoint",
        "description": "The Streamable HTTP transport for one product's MCP server. Speak JSON-RPC 2.0 over it: `initialize`, `tools/list`, `tools/call`. Most clients need only the URL — paste https://www.empyre.dev/mcp (or the relay, vault or ledger host) into an MCP client and sign in. The server is stateless: there is no session id, and GET and DELETE answer 405 by design.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "product_id", "in": "path", "required": true, "description": "Which product's server to talk to.", "schema": { "type": "string", "enum": ["empyre", "relay", "vault", "ledger"] } }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JsonRpcRequest" } } }
        },
        "responses": {
          "200": { "description": "A JSON-RPC response, or an SSE stream when the client accepted text/event-stream.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JsonRpcResponse" } } } },
          "401": { "description": "Authorization is required. The WWW-Authenticate header names the protected-resource metadata document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "405": { "description": "The method is not GET or DELETE on this stateless server." }
        }
      }
    },
    "/mcp/{product_id}/.well-known/oauth-protected-resource": {
      "get": {
        "tags": ["mcp"],
        "operationId": "getMcpProtectedResourceMetadata",
        "summary": "Protected-resource metadata",
        "description": "RFC 9728 metadata for one product's MCP server: which authorization server issues its tokens and which scopes it accepts. An MCP client reads this to discover how to sign in.",
        "parameters": [
          { "name": "product_id", "in": "path", "required": true, "description": "Which product's server to describe.", "schema": { "type": "string", "enum": ["empyre", "relay", "vault", "ledger"] } }
        ],
        "responses": {
          "200": { "description": "The protected-resource metadata document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProtectedResourceMetadata" } } } },
          "404": { "description": "No such product.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/company-content/posts": {
      "get": {
        "tags": ["company-content"],
        "operationId": "listCompanyPosts",
        "summary": "List a company's published posts",
        "description": "The paginated post list a company built by Empyre serves its own blog from. Server-rendered content, because most AI crawlers do not execute JavaScript. Identify the company with the X-Empyre-Company-Id header.",
        "parameters": [
          { "name": "limit", "in": "query", "required": false, "description": "How many posts to return.", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
          { "name": "cursor", "in": "query", "required": false, "description": "The `next_cursor` from a previous page.", "schema": { "type": "string" } },
          { "name": "category", "in": "query", "required": false, "description": "Filter to one category slug.", "schema": { "type": "string" } },
          { "name": "author", "in": "query", "required": false, "description": "Filter to one author slug.", "schema": { "type": "string" } },
          { "name": "tag", "in": "query", "required": false, "description": "Filter to one tag.", "schema": { "type": "string" } },
          { "name": "sort", "in": "query", "required": false, "description": "Ordering.", "schema": { "type": "string", "enum": ["newest", "oldest"], "default": "newest" } },
          { "$ref": "#/components/parameters/CompanyId" }
        ],
        "responses": {
          "200": { "description": "One page of published posts.", "content": { "application/json": { "schema": { "type": "object", "properties": { "posts": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } }, "next_cursor": { "type": ["string", "null"], "description": "Pass as `cursor` for the next page. Null on the last page." } }, "required": ["posts"] } } } },
          "404": { "description": "No such company.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/company-content/post": {
      "get": {
        "tags": ["company-content"],
        "operationId": "getCompanyPost",
        "summary": "Read one published post",
        "description": "Returns a single published post by slug, including its rendered body, canonical URL and structured-data fields.",
        "parameters": [
          { "name": "slug", "in": "query", "required": true, "description": "The post's URL slug.", "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/CompanyId" }
        ],
        "responses": {
          "200": { "description": "The post.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } },
          "404": { "description": "No published post with that slug.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/company-content/routes": {
      "get": {
        "tags": ["company-content"],
        "operationId": "listCompanyContentRoutes",
        "summary": "Every content URL, for a sitemap",
        "description": "Every published content URL with its last-modified date. A generated company builds its sitemap from this, so the sitemap cannot drift from what is actually published.",
        "parameters": [{ "$ref": "#/components/parameters/CompanyId" }],
        "responses": {
          "200": { "description": "The routes.", "content": { "application/json": { "schema": { "type": "object", "properties": { "routes": { "type": "array", "items": { "type": "object", "properties": { "loc": { "type": "string", "description": "The path, relative to the site root." }, "lastmod": { "type": ["string", "null"], "format": "date", "description": "Last modification date, or null when unknown." } }, "required": ["loc"] } } }, "required": ["routes"] } } } }
        }
      }
    },
    "/company-content/support-contact": {
      "get": {
        "tags": ["company-content"],
        "operationId": "getCompanySupportContact",
        "summary": "Whether to offer a human contact",
        "description": "Answers one question at render time: should this company show a talk-to-a-human contact route? True when it can take no payments yet, because a customer who cannot buy needs somewhere to ask. The company's own allocated address is used; generated code never composes it.",
        "parameters": [{ "$ref": "#/components/parameters/CompanyId" }],
        "responses": {
          "200": { "description": "Whether to show the contact route, and the address to use.", "content": { "application/json": { "schema": { "type": "object", "properties": { "show_contact": { "type": "boolean" }, "email": { "type": ["string", "null"], "format": "email" } }, "required": ["show_contact"] } } } }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A Relay or MCP access token, presented as `Authorization: Bearer <token>`."
      }
    },
    "parameters": {
      "CompanyId": {
        "name": "X-Empyre-Company-Id",
        "in": "header",
        "required": false,
        "description": "Which company's content to read. Sent by the company's own server; on that company's own host it is inferred and may be omitted.",
        "schema": { "type": "string", "format": "uuid" }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "A refusal in the platform's ordinary shape.",
        "properties": { "detail": { "type": "string", "description": "What went wrong, in a sentence safe to show a person." } },
        "required": ["detail"]
      },
      "OAuthError": {
        "type": "object",
        "description": "An RFC 6749 error. `error` is a fixed enum; `error_description` is free text and must never be parsed.",
        "properties": {
          "error": { "type": "string", "enum": ["invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", "unsupported_grant_type", "invalid_scope", "access_denied", "server_error", "temporarily_unavailable"] },
          "error_description": { "type": "string" }
        },
        "required": ["error"]
      },
      "TokenRequest": {
        "type": "object",
        "description": "An authorization-code exchange or a refresh.",
        "properties": {
          "grant_type": { "type": "string", "enum": ["authorization_code", "refresh_token"] },
          "code": { "type": "string", "description": "The one-time code from the redirect. Required for authorization_code." },
          "redirect_uri": { "type": "string", "format": "uri", "description": "The same redirect URI used at /authorize. Required for authorization_code." },
          "code_verifier": { "type": "string", "description": "The PKCE verifier whose S256 hash was sent as code_challenge. Required for authorization_code." },
          "refresh_token": { "type": "string", "description": "Required for refresh_token." },
          "client_id": { "type": "string", "description": "Public clients send it here; confidential clients use HTTP Basic instead." }
        },
        "required": ["grant_type"]
      },
      "TokenResponse": {
        "type": "object",
        "properties": {
          "access_token": { "type": "string" },
          "token_type": { "type": "string", "enum": ["Bearer"] },
          "expires_in": { "type": "integer", "description": "Seconds until the access token expires." },
          "refresh_token": { "type": "string", "description": "Rotated on every use. Store this one and discard the previous." },
          "scope": { "type": "string", "description": "The scopes actually granted, which may be narrower than those requested." }
        },
        "required": ["access_token", "token_type", "expires_in"]
      },
      "UserInfo": {
        "type": "object",
        "description": "Only the claims the token's scopes allow.",
        "properties": {
          "sub": { "type": "string", "description": "The stable subject identifier. Always present." },
          "name": { "type": "string", "description": "Requires the `profile` scope." },
          "preferred_username": { "type": "string", "description": "Requires the `profile` scope." },
          "email": { "type": "string", "format": "email", "description": "Requires the `email` scope." },
          "email_verified": { "type": "boolean", "description": "Requires the `email` scope." }
        },
        "required": ["sub"]
      },
      "Introspection": {
        "type": "object",
        "properties": {
          "active": { "type": "boolean", "description": "False for a token that is unknown, expired or revoked. The only field guaranteed present." },
          "scope": { "type": "string" },
          "client_id": { "type": "string" },
          "sub": { "type": "string" },
          "exp": { "type": "integer", "description": "Expiry, as seconds since the epoch." }
        },
        "required": ["active"]
      },
      "PublicClient": {
        "type": "object",
        "description": "A client registration, with nothing secret in it.",
        "properties": {
          "client_id": { "type": "string" },
          "name": { "type": "string", "description": "The display name shown on the consent screen." },
          "logo_url": { "type": ["string", "null"], "format": "uri" },
          "domain": { "type": "string" }
        },
        "required": ["client_id", "name"]
      },
      "McpProduct": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "enum": ["empyre", "relay", "vault", "ledger"] },
          "name": { "type": "string" },
          "endpoint": { "type": "string", "format": "uri", "description": "Paste this into an MCP client." },
          "description": { "type": "string", "description": "What this server's tools can do." }
        },
        "required": ["id", "name", "endpoint"]
      },
      "ProtectedResourceMetadata": {
        "type": "object",
        "properties": {
          "resource": { "type": "string", "format": "uri" },
          "authorization_servers": { "type": "array", "items": { "type": "string", "format": "uri" } },
          "scopes_supported": { "type": "array", "items": { "type": "string" } },
          "bearer_methods_supported": { "type": "array", "items": { "type": "string" } }
        },
        "required": ["resource", "authorization_servers"]
      },
      "JsonRpcRequest": {
        "type": "object",
        "description": "A JSON-RPC 2.0 request. MCP defines the methods.",
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id": { "type": ["string", "integer"], "description": "Omit for a notification." },
          "method": { "type": "string", "description": "An MCP method, for example initialize, tools/list or tools/call.", "examples": ["tools/list"] },
          "params": { "type": "object", "description": "Method parameters, as defined by MCP.", "additionalProperties": true }
        },
        "required": ["jsonrpc", "method"]
      },
      "JsonRpcResponse": {
        "type": "object",
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id": { "type": ["string", "integer", "null"] },
          "result": { "type": "object", "additionalProperties": true, "description": "Present on success." },
          "error": { "type": "object", "additionalProperties": true, "description": "Present on failure. Carries code and message." }
        },
        "required": ["jsonrpc"]
      },
      "Post": {
        "type": "object",
        "description": "One published post.",
        "properties": {
          "slug": { "type": "string" },
          "title": { "type": "string" },
          "excerpt": { "type": ["string", "null"] },
          "body_html": { "type": ["string", "null"], "description": "The rendered body. Present on the single-post read." },
          "published_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" },
          "canonical_url": { "type": ["string", "null"], "format": "uri" },
          "author": { "type": ["string", "null"] },
          "category": { "type": ["string", "null"] },
          "tags": { "type": "array", "items": { "type": "string" } }
        },
        "required": ["slug", "title"]
      }
    }
  }
}
