{
  "openapi": "3.0.3",
  "info": {
    "title": "Nappr Global API",
    "description": "Public contract for the Nappr Global API machine surface, browser surface, and operator health. Hand-curated to match the deployed platform.",
    "version": "2026.08.14+webhooks",
    "contact": {
      "name": "Nappr Global API",
      "email": "platform@nappr.example"
    },
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://api.nappr.io",
      "description": "Production"
    },
    {
      "url": "http://127.0.0.1:8031",
      "description": "Local development"
    }
  ],
  "tags": [
    {
      "name": "operator",
      "description": "Operator health and status endpoints."
    },
    {
      "name": "machine",
      "description": "Authenticated machine API. Requires an API client (issued by an administrator)."
    },
    {
      "name": "browser",
      "description": "Browser-facing pages (Razor). Anonymous-friendly where indicated."
    }
  ],
  "paths": {
    "/healthz": {
      "get": {
        "tags": [
          "operator"
        ],
        "summary": "DB-aware readiness probe",
        "description": "Returns 200 with `{\"status\":\"Healthy\",\"checks\":[...]}` when the database is reachable. Returns 503 when degraded.",
        "responses": {
          "200": {
            "description": "Healthy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthReport"
                }
              }
            }
          },
          "503": {
            "description": "Degraded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthReport"
                }
              }
            }
          }
        }
      }
    },
    "/healthz/live": {
      "get": {
        "tags": [
          "operator"
        ],
        "summary": "Always-on liveness probe",
        "description": "Returns 200 with no dependencies. Used by orchestrators.",
        "responses": {
          "200": {
            "description": "Alive"
          }
        }
      }
    },
    "/api/v1/platform/me": {
      "get": {
        "tags": [
          "machine"
        ],
        "summary": "Calling API client identity",
        "description": "Returns the calling API client's tenant id, API client id, and granted scopes. Requires API client credentials in `X-Api-Client-Identifier` and `X-Api-Client-Secret` headers (or `Authorization: ApiKey {id}:{secret}`).",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Identity",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MachineIdentity"
                }
              }
            }
          },
          "401": {
            "description": "Bad credentials",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
        "/api/v1/tenants/{tenantId}": {
      "get": {
        "tags": ["machine"],
        "summary": "Read the tenant profile",
        "description": "Returns the partner's tenant profile (id, slug, displayName, billingContactEmail, clientPhone, status, createdAt, updatedAt, concurrencyToken). Requires the `tenant.admin` scope. Cross-tenant calls return 404.",
        "security": [{ "ApiKey": [] }],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "format": "uuid" }
          }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TenantProfile" } } } },
          "404": { "description": "Tenant not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } }
        }
      },
      "patch": {
        "tags": ["machine"],
        "summary": "Update the tenant profile",
        "description": "Updates DisplayName, BillingContactEmail, ClientPhone. Status and slug are not editable here. Requires the `tenant.admin` scope and a valid concurrency token.",
        "security": [{ "ApiKey": [] }],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "format": "uuid" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateTenantRequest" } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TenantProfile" } } } },
          "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
          "404": { "description": "Tenant not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
          "409": { "description": "Stale concurrency token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/platform/me": {
      "get": {
        "tags": [
          "machine"
        ],
        "summary": "Calling API client identity (tenant-scoped)",
        "description": "Same payload as `/api/v1/platform/me`, but bound to the tenant in the URL. The API client's `tenant_id` must equal the path `tenantId`; mismatches return 404 so tenant existence is not leaked.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "description": "Tenant id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Identity",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MachineIdentity"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Tenant not found or API client does not belong to it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/api-clients/issue": {
      "post": {
        "tags": [
          "machine"
        ],
        "summary": "Issue a new API client for the tenant",
        "description": "Creates a new API client under the calling tenant and returns the secret (one-time display). Requires the `api_client.manage` scope. Cross-tenant calls return 404.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "description": "Tenant id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/IssueApiClientRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "API client issued",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiClientWithSecret"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Tenant not found or API client does not belong to it."
          }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/api-clients/{clientId}/rotate": {
      "post": {
        "tags": [
          "machine"
        ],
        "summary": "Rotate an API client's secret",
        "description": "Generates a new secret and invalidates the old one. Caller must own the client (cross-tenant returns 404).",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "description": "Tenant id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "clientId",
            "in": "path",
            "required": true,
            "description": "API client id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RotateApiClientRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rotated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiClientWithSecret"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "API client not found in this tenant."
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/api-clients/{clientId}/compromise-rotation": {
      "post": {
        "tags": [
          "machine"
        ],
        "summary": "Compromise-rotate an API client's secret",
        "description": "Generates a new secret, invalidates the old one, and emits a discrete `api_client.compromised` audit row plus webhook event. Semantically distinct from /rotate: use this when the credential was leaked, suspected, or otherwise must be flagged as compromised. Caller must own the client (cross-tenant returns 404).",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "description": "Tenant id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "clientId",
            "in": "path",
            "required": true,
            "description": "API client id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RotateApiClientRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Compromise-rotation succeeded; new secret returned (shown once) and an `api_client.compromised` audit row was emitted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiClientWithSecret"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Tenant is not active (Closed / Suspended). Machine access is denied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "tenant_closed"
                    },
                    "message": {
                      "type": "string",
                      "example": "Tenant is not active; machine access is denied."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "API client not found in this tenant."
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/api-clients": {
      "get": {
        "tags": [
          "machine"
        ],
        "summary": "List the calling tenant's API clients",
        "description": "Paginated list of API clients owned by the calling tenant. Requires the `api_client.manage` scope.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "description": "Tenant id (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "take",
            "in": "query",
            "description": "Page size (1-100, default 25).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "name": "skip",
            "in": "query",
            "description": "Offset.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of API clients",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiClientPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Tenant not found."
          }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/webhooks": {
      "post": {
        "tags": [
          "machine"
        ],
        "summary": "Subscribe to webhook events for the tenant",
        "description": "Creates a new subscription and returns the plaintext signing secret (one-time display).",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscribeWebhookRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscriptionWithSecret"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Tenant not found."
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "get": {
        "tags": [
          "machine"
        ],
        "summary": "List active webhook subscriptions",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer"
                    },
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookSubscriptionRow"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
        "/api/v1/tenants/{tenantId}/webhooks/{subscriptionId}/rotate-secret": {
      "post": {
        "tags": ["machine"],
        "summary": "Rotate the webhook signing secret",
        "description": "Generates a new plaintext secret for the subscription. Requires the `webhooks.manage` scope and a valid concurrency token. The new secret is returned in the response body; the old secret stops signing immediately.",
        "security": [{ "ApiKey": [] }],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "format": "uuid" }
          },
          {
            "name": "subscriptionId",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "format": "uuid" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateSecretRequest" } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateSecretResponse" } } } },
          "404": { "description": "Subscription not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
          "409": { "description": "Stale concurrency token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } }
        }
      }
    },
    "/api/v1/tenants/{tenantId}/webhooks/{subscriptionId}": {
      "delete": {
        "tags": [
          "machine"
        ],
        "summary": "Revoke a webhook subscription",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "tenantId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "subscriptionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Subscription not found in this tenant."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Client-Identifier",
        "description": "API client authentication. Send `X-Api-Client-Identifier` and `X-Api-Client-Secret` headers. Alternative: `Authorization: ApiKey {identifier}:{secret}`."
      }
    },
    "schemas": {
      "HealthReport": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "Healthy",
              "Degraded",
              "Unhealthy"
            ]
          },
          "checks": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "Healthy",
                    "Degraded",
                    "Unhealthy"
                  ]
                },
                "durationMs": {
                  "type": "integer",
                  "format": "int64"
                }
              }
            }
          }
        }
      },
      "MachineIdentity": {
        "type": "object",
        "properties": {
          "tenantId": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "apiClientId": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "ApiError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "IssueApiClientRequest": {
        "type": "object",
        "required": [
          "name",
          "scopes"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 1
          }
        }
      },
      "RotateApiClientRequest": {
        "type": "object",
        "required": [
          "concurrencyToken"
        ],
        "properties": {
          "concurrencyToken": {
            "type": "string",
            "minLength": 1
          }
        }
      },
      "ApiClientWithSecret": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "identifier": {
            "type": "string"
          },
          "secret": {
            "type": "string",
            "description": "Plaintext secret. Shown once at issue/rotate; stored only as a hash."
          },
          "prefix": {
            "type": "string"
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "rotatedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "concurrencyToken": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "ApiClientRow": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "identifier": {
            "type": "string"
          },
          "prefix": {
            "type": "string"
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "isActive": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "rotatedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "revokedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "concurrencyToken": {
            "type": "string"
          }
        }
      },
      "ApiClientPage": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "description": "Items in this page."
          },
          "total": {
            "type": "integer",
            "description": "Total items across all pages."
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApiClientRow"
            }
          }
        }
      },
      "SubscribeWebhookRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "eventTypes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Event types to subscribe to. Empty or absent = all events."
          }
        }
      },
      "WebhookSubscriptionWithSecret": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string"
          },
          "eventTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "secret": {
            "type": "string",
            "description": "Plaintext signing secret. Shown once at subscribe; stored only as plaintext for HMAC verification."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "concurrencyToken": {
            "type": "string"
          }
        }
      },
          "TenantProfile": {
      "type": "object",
      "properties": {
        "id": { "type": "string", "format": "uuid" },
        "slug": { "type": "string" },
        "displayName": { "type": "string" },
        "billingContactEmail": { "type": "string", "nullable": true },
        "clientPhone": { "type": "string", "nullable": true },
        "status": { "type": "string", "enum": ["Pending", "Active", "Suspended", "Closed"] },
        "createdAt": { "type": "string", "format": "date-time" },
        "updatedAt": { "type": "string", "format": "date-time" },
        "concurrencyToken": { "type": "string" }
      }
    },
    "UpdateTenantRequest": {
      "type": "object",
      "required": ["displayName", "concurrencyToken"],
      "properties": {
        "displayName": { "type": "string", "maxLength": 200 },
        "billingContactEmail": { "type": "string", "nullable": true, "maxLength": 320 },
        "clientPhone": { "type": "string", "nullable": true, "maxLength": 40 },
        "concurrencyToken": { "type": "string" }
      }
    },
    "RotateSecretRequest": {
      "type": "object",
      "required": ["concurrencyToken"],
      "properties": {
        "concurrencyToken": { "type": "string" }
      }
    },
    "RotateSecretResponse": {
      "type": "object",
      "properties": {
        "id": { "type": "string", "format": "uuid" },
        "secret": { "type": "string" },
        "concurrencyToken": { "type": "string" }
      }
    },
"WebhookSubscriptionRow": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string"
          },
          "eventTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "concurrencyToken": {
            "type": "string"
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Bad credentials",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "BadRequest": {
        "description": "Bad request",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string"
                },
                "field": {
                  "type": "string",
                  "nullable": true
                }
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "Concurrency conflict",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }
  }
}