Skip to content

REST API Reference

Base URL: http://app.localhost:3000/api/v1

All protected endpoints require Authorization: Bearer <token>.

JWT access tokens and API keys use the same header format:

Authorization: Bearer <access_token_or_easm_api_key>

Update Center endpoints are local hxEASM admin endpoints under /api/v1/admin/updates/*. The browser never calls the Central Update API directly, and the Central Update API key is backend-only.

Auth

Method Path Description
POST /auth/register Create account
POST /auth/login Login, returns token pair
POST /auth/refresh Refresh access token
POST /auth/2fa/verify Verify a login 2FA challenge
POST /auth/2fa/resend Resend email OTP for a login challenge
POST /auth/2fa/email/enable Required-login email OTP enrollment
GET /me/me Current user
GET /me/avatar Download own avatar image
POST /me/avatar Upload own avatar image
DELETE /me/avatar Remove own avatar
GET /me/2fa Own 2FA status
POST /me/2fa/totp/setup Start authenticator app enrollment
POST /me/2fa/totp/confirm Confirm authenticator app enrollment
POST /me/2fa/email/enable Start or confirm email OTP enrollment
POST /me/2fa/disable Disable own 2FA
GET /me/users List users (admin)
GET /me/api-keys List own API keys
POST /me/api-keys Create API key
DELETE /me/api-keys/{id} Revoke API key

POST /auth/login

{ "email": "user@example.com", "password": "secret" }
→ { "tokens": { "access_token": "…", "refresh_token": "…" }, "user": { … } }

When 2FA is required, no tokens are returned until the challenge is verified:

{
  "requires_2fa": true,
  "challenge_id": "...",
  "method": "totp"
}

Then call:

POST /api/v1/auth/2fa/verify
{ "challenge_id": "...", "code": "123456" }

Admin policy endpoints:

Method Path Description
GET /admin/auth/2fa Read global 2FA policy
PUT /admin/auth/2fa Update global 2FA policy

POST /me/api-keys

Create an API key:

{
  "name": "Assets read key",
  "permissions": ["read:assets"]
}

Response:

{
  "key": "easm_...",
  "api_key": {
    "id": "...",
    "name": "Assets read key",
    "key_prefix": "easm_...",
    "permissions": ["read:assets"]
  }
}

The raw key is only returned once when created.

User avatar

POST /me/avatar accepts multipart form data with a single avatar file field.

Allowed content types:

  • image/png
  • image/jpeg
  • image/webp

Maximum size: 2 MiB.

Avatar uploads and deletion are self-service only. Image bytes are stored in object storage; the users table stores private object metadata and does not store base64 image data. GET /me/avatar streams the current user's avatar through the authenticated API.

Organizations

Method Path Description
GET /organizations List orgs (admin: all; others: assigned)
POST /organizations Create org
GET /organizations/{id} Get org
PATCH /organizations/{id} Update org
DELETE /organizations/{id} Delete org
GET /organizations/{id}/members List members
POST /organizations/{id}/members Add member

Scopes

Method Path Description
GET /organizations/{id}/scopes List scopes
POST /organizations/{id}/scopes Create scope (→ pending)
PATCH /scopes/{scope_id} Update scope
POST /scopes/{scope_id}/approve Approve (admin)
POST /scopes/{scope_id}/reject Reject (admin)

POST /organizations/{id}/scopes

{ "type": "domain", "value": "example.com", "description": "Main domain" }

Scans

Method Path Description
GET /organizations/{id}/scans List scans
POST /organizations/{id}/scans Create & queue scan
GET /scans/{scan_id} Get scan
POST /scans/{scan_id}/cancel Cancel scan
GET /scans/{scan_id}/jobs List scan jobs

POST /organizations/{id}/scans

{ "profile": "default", "scope_ids": ["<scope-uuid>"] }

Profiles are returned by GET /api/v1/scan-profiles and are defined in backend/configs/config.yaml.

Audit Log

Method Path Description
GET /admin/audit List append-only user-action audit events (admin only)

Query params:

  • user_id
  • action
  • event_type
  • resource_type
  • resource_id
  • organization_id
  • from
  • to
  • page
  • page_size

Results are sorted newest first. Default page size is 50; maximum page size is 200.

Audit Log records user/admin mutations and configuration changes. It does not audit ordinary GET/read requests and is separate from Exposure Changes.

System Backup

Method Path Description
POST /admin/backup/export Export an easm-backup-v3 ZIP bundle (admin only)
POST /admin/backup/import/preview Validate a backup ZIP and return counts/warnings/errors (admin only)
POST /admin/backup/import Import a valid backup ZIP (admin only)

Export request:

{
  "include_files": true,
  "include_scan_history": true,
  "include_settings": true
}

Import uses multipart form field file plus conflict_mode, one of skip_existing, merge, or overwrite_metadata_only.

Backup V3 preserves current non-secret product state, including Assets, Asset Graph, Asset System History, Threats, Threat History, optional Asset Scan History, files metadata, exposure changes, scan profiles, scheduled scans, plugin settings, and scan settings. Secrets, API keys, 2FA material, memberships, and Audit Log are intentionally not restored.

Fatal DB integrity errors are returned as import failures rather than ordinary warnings. Expected conflicts are reported in skipped_expected.

Scan Settings

Method Path Description
GET /admin/scan-settings Get global scan behavior settings (admin only)
PUT /admin/scan-settings Update global scan behavior settings (admin only)

Request/response:

{
  "auto_approve_discovered_assets": false
}

Default is false. The setting applies only to newly inserted scan/plugin-discovered assets and never retroactively modifies existing assets.

Assets

Method Path Description
GET /organizations/{id}/assets List assets (paginated)
POST /organizations/{id}/assets Manually create an inventory asset (admin/hacker)
PATCH /organizations/{id}/assets/bulk Bulk approve selected inventory assets (admin/hacker)
GET /organizations/{id}/assets/graph Get asset graph
GET /organizations/{id}/assets/stats Count by type
GET /assets/{asset_id} Get single asset
GET /assets/{asset_id}/history Read dedicated per-Asset lifecycle history
PATCH /assets/{asset_id}/criticality Manually update asset criticality (admin/hacker)
PATCH /assets/{asset_id}/lifecycle Update approved/disabled/stale state (admin/hacker)
POST /assets/{asset_id}/run-plugin Run one compatible plugin against one asset (admin/hacker)
GET /plugins List registry-defined plugin catalog metadata (admin/hacker)
GET /plugins/manual-capabilities List registry-defined manual plugin capabilities (admin/hacker)
GET /admin/scan-profiles List built-in and custom scan profiles (admin)
POST /admin/scan-profiles Create a database-backed custom scan profile (admin)
PATCH /admin/scan-profiles/{id} Update a custom scan profile (admin)
DELETE /admin/scan-profiles/{id} Delete a custom scan profile (admin)
GET /admin/plugins/{plugin_id}/settings Read validated plugin settings (admin)
PUT /admin/plugins/{plugin_id}/settings Save validated plugin settings (admin)

Query params for list: type, criticality, approved, disabled, stale, sort_by, sort_order, page, page_size.

Allowed asset sort fields are type, value, criticality, lifecycle, source, confidence, first_discovered, and threat_count. sort_order accepts asc or desc. Criticality uses semantic ordering (unknown, low, medium, high, critical) instead of alphabetical ordering. threat_count is returned on each asset list item and counts Threat records associated through threats.asset_id.

POST /organizations/{id}/assets

Create an asset directly in inventory. Allowed roles: admin, hacker. Organization access is enforced by the route; clients are denied.

Request:

{
  "type": "domain",
  "value": "example.com",
  "criticality": "unknown",
  "approved": true,
  "disabled": false,
  "stale": false
}

Supported manual Asset types:

domain, subdomain, ip, service, webapp, certificate

The backend validates the value for the selected type and normalizes it using the asset inventory normalization rules. Manual assets are stored with source_plugin=manual. The system-managed organization Asset is not manually creatable. Legacy create requests using url are mapped to webapp; removed types such as cidr, ip_range, asn, port, path, parameter, and technology are rejected for new manual Asset creation. If an asset already exists with the same organization_id, type, and normalized_value, the API returns 409 Conflict.

Creating an Asset manually does not create or approve a Scan Scope and does not start a scan.

Asset responses include lifecycle state:

{
  "approved": true,
  "disabled": false,
  "stale": false,
  "criticality": "high"
}

Asset Lifecycle

approved means a user has confirmed the asset. Existing assets are approved during migration, while new assets created from approved scopes are approved at creation time. Automatically discovered assets are unapproved by default unless auto_approve_discovered_assets is enabled. Rediscovery never overwrites an existing asset's approval state.

disabled keeps the asset in inventory but prevents new asset-derived manual plugin execution and excludes the saved asset from target reconstruction. It does not delete the asset or suppress an independently approved scan scope.

stale is a manual persisted flag for assets considered outdated. Automatic stale rules are not implemented yet.

PATCH /api/v1/assets/{asset_id}/lifecycle

Request:

{ "approved": true, "disabled": false, "stale": true }

Allowed roles: admin, hacker. Clients can read lifecycle state but cannot modify it.

GET /assets/{asset_id}/history

Read the dedicated per-Asset history timeline. Access follows the existing asset RBAC rules.

Query params:

Parameter Meaning
page Page number, default 1.
page_size Page size, default 20, maximum 100.
event_type Optional event type filter.
source_type Optional source filter: user, scan, or system.

Response:

{
  "items": [
    {
      "id": "history-id",
      "organization_id": "organization-id",
      "asset_id": "asset-id",
      "event_type": "asset.criticality.changed",
      "field": "criticality",
      "old_value": "medium",
      "new_value": "high",
      "source_type": "user",
      "actor_user_id": "user-id",
      "actor_username": "admin@example.com",
      "metadata": {
        "asset_type": "service"
      },
      "created_at": "2026-08-23T12:00:00Z"
    }
  ],
  "total": 1
}

Tracked V1 event types:

  • asset.discovered
  • asset.created.manual
  • asset.approval.changed
  • asset.disabled.changed
  • asset.stale.changed
  • asset.criticality.changed

Asset History is append-only through normal application APIs. There are no user-facing create, update, or delete history endpoints.

Asset History does not backfill existing assets and does not record noisy refresh fields such as last_seen_at, updated_at, or full metadata churn.

GET /assets/{asset_id}/scan-changes

Read field-level scanner-observed changes for an Asset. Access follows the existing asset RBAC rules. The endpoint returns asset_scan_changes only; snapshots are not exposed through this API.

Query params:

Parameter Meaning
period Optional 7d, 30d, 90d, or all.
limit Optional max rows, default 100, maximum 200.

Response:

{
  "items": [
    {
      "id": "change-id",
      "asset_id": "asset-id",
      "scan_id": "scan-id",
      "scan_job_id": "scan-job-id",
      "change_type": "changed",
      "field_path": "service.version",
      "old_value": "1.22",
      "new_value": "1.24",
      "source_plugin": "nmap",
      "observed_at": "2026-09-12T10:00:00Z",
      "created_at": "2026-09-12T10:00:01Z"
    }
  ],
  "total": 1
}

GET /assets/{asset_id}/change-summary

Read day-bucketed counts for the Asset Detail change chart. Access follows the existing asset RBAC rules.

Query params:

Parameter Meaning
period Optional 7d, 30d, 90d, or all; default 30d.

Response:

{
  "period": "30d",
  "buckets": [
    {
      "bucket_start": "2026-09-12T00:00:00Z",
      "system_changes": 1,
      "scan_changes": 3
    }
  ],
  "total": {
    "system_changes": 1,
    "scan_changes": 3
  }
}

PATCH /organizations/{id}/assets/bulk

Bulk approve selected inventory assets. Allowed roles: admin, hacker. The route organization controls access; clients are denied.

Request:

{
  "asset_ids": ["asset-uuid-1", "asset-uuid-2"],
  "approved": true
}

Response:

{
  "requested": 14,
  "updated": 10,
  "unchanged": 4
}

The endpoint is intentionally narrow and only supports approved=true. Batch size is limited to 500 IDs. The operation validates that all requested assets belong to the route organization before updating. Disabled, stale, and criticality values are not changed. In the frontend, the All Assets header checkbox selects only the currently loaded page/list response, not every matching asset across all pages.

Asset Criticality

Asset responses include criticality, criticality_source, criticality_updated_at, and criticality_updated_by. Values are unknown, low, medium, high, and critical; new assets default to unknown.

Criticality is business importance, not vulnerability severity. A low-severity issue on a critical VPN may be more important than a medium issue on a test host.

For this MVP, criticality is manual-only and criticality_source is always manual. hxEASM does not infer it from hostnames, banners, technologies, vulnerabilities, exposure changes, LLMs, agents, or heuristics. Suggested criticality and future risk scoring are planned future work.

PATCH /api/v1/assets/{asset_id}/criticality

Allowed roles: admin, hacker. Clients can read criticality but cannot modify it. The endpoint uses the same organization-scoped asset access checks as other asset endpoints.

Request:

{ "criticality": "high" }

Response: the updated asset.

POST /assets/{asset_id}/run-plugin

Launch a single plugin against a selected asset. This creates a normal queued scan with internal profile manual_scan and exactly one scan job. Allowed roles: admin, hacker. Clients are denied.

Request:

{ "plugin": "nuclei" }

Response:

{
  "scan_id": "...",
  "scan_job_id": "...",
  "status": "queued"
}

If the plugin does not support the selected asset type, the API returns 400:

{ "error": "plugin katana does not support asset type ip" }

Manual scans appear in GET /organizations/{id}/scans like any other scan. Their scan metadata includes manual, asset_id, asset_type, asset_value, and plugin.

If the selected asset is disabled, the API returns 400 and does not queue a scan:

{ "error": "asset is disabled and cannot be used for plugin execution" }

GET /plugins/manual-capabilities

Returns enabled plugins that declare manual_scan in supported_execution_modes. The frontend uses this endpoint to build the Run Plugin menu dynamically; there is no frontend plugin allowlist.

[
  {
    "id": "httpx",
    "name": "httpx",
    "type": "http_probe",
    "supported_asset_types": ["domain", "subdomain", "ip", "service"],
    "supported_execution_modes": ["profile_scan", "manual_scan", "retry"],
    "manual_execution": true
  }
]

Disabled plugins and plugins without manual_scan are omitted.

API key example: read:assets

Use an API key as a Bearer token. For example, with:

easm_b3d070d84cf7c121927d0bcbed23fcc287aaa29575f8d5dffe54919588458e0b

List assets for an organization:

curl "http://app.localhost:3000/api/v1/organizations/<organization_id>/assets?page=1&page_size=20" \
  -H "Authorization: Bearer easm_b3d070d84cf7c121927d0bcbed23fcc287aaa29575f8d5dffe54919588458e0b"

Filter by asset type:

curl "http://app.localhost:3000/api/v1/organizations/<organization_id>/assets?type=domain&page=1&page_size=20" \
  -H "Authorization: Bearer easm_b3d070d84cf7c121927d0bcbed23fcc287aaa29575f8d5dffe54919588458e0b"

Get the asset graph:

curl "http://app.localhost:3000/api/v1/organizations/<organization_id>/assets/graph" \
  -H "Authorization: Bearer easm_b3d070d84cf7c121927d0bcbed23fcc287aaa29575f8d5dffe54919588458e0b"

Replace <organization_id> with the UUID of the organization to query.

Threats

Method Path Description
GET /organizations/{id}/threats List Threats with affected asset context (paginated)
POST /organizations/{id}/threats Create a manual Threat
GET /organizations/{id}/threats/stats Count Threats by severity
GET /organizations/{id}/threats/{threat_id}/detections List scan detections recorded for a Threat
POST /organizations/{id}/threats/{threat_id}/exclusion Add the Threat finding to persistent exclusions
GET /organizations/{id}/threat-exclusions List persistent Threat exclusions
DELETE /organizations/{id}/threat-exclusions/{exclusion_id} Remove a persistent Threat exclusion
GET /threats/{id} Get a single Threat with affected asset context
PATCH /threats/{id} Update mutable Threat fields. has_exploit is vulnerability-only.
PATCH /threats/{id}/status Update workflow status
POST /threats/{id}/retest Request vulnerability retest

Compatibility: existing /vulnerabilities routes remain available for legacy clients, but they expose only threat_type=vulnerability records. Legacy create requests always create vulnerability Threats, even if threat_type is omitted or set differently. New integrations should use /threats.

GET /organizations/{id}/threats

Supported query parameters:

Parameter Description
threat_type Threat type: vulnerability, data_leak, misconfiguration, phishing, or employee.
severity Existing severity filter.
status Existing status filter.
asset_id Exact affected asset UUID.
asset_type Asset type, for example ip, service, or webapp.
asset Case-insensitive search across asset value and common target fields.
source_plugin Source plugin, for example nuclei or nmap_vulns.
port Vulnerability metadata port filter.
protocol Vulnerability metadata protocol filter.
has_exploit Vulnerability-only filter. When set, results are narrowed to vulnerability Threats.
page, page_size Pagination.

Each item includes common Threat fields plus current asset context when available: asset_type, asset_value, asset_criticality, asset_source_plugin, scan_id, and scan_job_id. risk_score is returned on a canonical 0-100 scale.

threat_type=vulnerability responses also include vulnerability-specific fields such as affected_host, affected_port, affected_protocol, affected_service, affected_url, cvss_score, has_exploit, and has_exploit_source. cvss_score remains the standard 0-10 CVSS value and is not the same metric as risk_score. Non-vulnerability Threat responses do not emit misleading top-level vulnerability defaults; their type-specific values are returned in type_details and details.

Plugin-discovered vulnerability Threats are deduplicated into one current Threat row. Repeated detections in later scan jobs are exposed through the organization-scoped detections endpoint. Historical Threats created before detection history may return an empty detections list.

Threat exclusions suppress future plugin detections by exact identity: organization_id + asset_id + external_id. The server derives these values from the persisted Threat; clients may send only an optional reason. source_plugin is retained on the exclusion as informational metadata for the rule that was originally created, but it does not participate in uniqueness or suppression matching. Creating an exclusion does not delete, close, or modify the existing Threat. Threats with an empty external_id cannot be excluded in V1. Removing an exclusion deletes only the suppression rule; historical Threat and detection rows remain unchanged, and future matching detections flow through the normal Threat upsert and detection-history pipeline again.

Example:

curl "http://app.localhost:3000/api/v1/organizations/<organization_id>/threats?threat_type=vulnerability&asset_type=service&asset=192.0.2.10&source_plugin=nmap_vulns" \
  -H "Authorization: Bearer <token>"

POST /organizations/{id}/threats

Creates a manual Threat in the organization. Allowed for administrators and hackers with organization access.

Required fields:

{
  "threat_type": "vulnerability",
  "asset_id": "<asset_uuid>",
  "title": "Exposed admin panel",
  "severity": "high",
  "description": "The admin panel is reachable from the internet."
}

Allowed threat_type values are vulnerability, data_leak, misconfiguration, phishing, and employee. All manual Threats start with status=new and close_reason=null.

Optional common fields include remediation and type_details. Vulnerability Threats may also include cve, cwes, references, has_exploit, affected_port, affected_protocol, affected_service, affected_url, and cvss_score.

PATCH /threats/{id}

Allowed for administrators and hackers with access to the Threat organization. The initial mutable field is vulnerability-only has_exploit.

{ "has_exploit": true }

Manual updates set has_exploit_source=manual.

PATCH /threats/{id}/status

{ "status": "closed", "reason": "fp", "note": "Verified manually" }

Valid status transitions: - new → active - new → closed with reason=fp - active → in_progress - active → closed with reason=skipped - in_progress → review - review → closed with reason=fixed - review → active

Closed Threats require reason to be one of fp, fixed, or skipped. Non-closed Threats must not include a close reason. Admin users may perform any valid workflow transition as an operational override; hacker users perform security-team transitions; client users perform customer-lane transitions for organizations they can access.

POST /threats/{id}/retest

Requests a retest for a vulnerability Threat in review. Allowed for administrators and hackers with access to the Threat organization. The action does not automatically mark the Threat fixed; an administrator or hacker must decide the final review → closed/fixed or review → active transition after retest evidence is reviewed.

Reports

Method Path Description
GET /organizations/{id}/reports List generated reports
POST /organizations/{id}/reports Create report generation request
GET /organizations/{id}/reports/{report_id} Get report metadata
GET /organizations/{id}/reports/{report_id}/download Download generated report file

Supported formats: json, csv, pdf, html.

Reports are organization-scoped. Admin, hacker, and client users can list, create, and download reports for organizations they are authorized to access. The server records the authenticated user as the report initiator; clients cannot supply or spoof another initiator.

Example request:

{
  "format": "pdf",
  "filters": {
    "severity_min": "medium",
    "scan_id": "<scan-uuid>"
  }
}

Example response:

{
  "id": "<report-uuid>",
  "organization_id": "<organization-uuid>",
  "created_by": "<user-uuid>",
  "initiated_by": {
    "id": "<user-uuid>",
    "username": "demo-client",
    "role": "client"
  },
  "format": "pdf",
  "status": "ready",
  "created_at": "2026-09-01T14:22:08Z"
}

Historical report records without created_by are returned without initiated_by; clients should display an unknown-user fallback. Downloads use dated filenames in Content-Disposition, for example hxeasm-report-2026-09-01_14-22-08-<report-prefix>.pdf.

PDF reports use the styled report renderer. HTML reports use the embedded template at backend/internal/reports/templates/easm_report.html.

Health check

GET /health
→ { "status": "ok", "time": "2026-04-29T…" }

Error format

All errors return JSON:

{ "error": "description" }

Exposure Changes

Method Path Description
GET /organizations/{id}/changes List exposure change timeline events
GET /organizations/{id}/changes/summary Summary counters for recent changes
GET /assets/{asset_id}/changes History for a single asset

List query params: change_type, entity_type, severity, asset_id, threat_id, scan_id, date_from, date_to, limit, offset. vulnerability_id is accepted as a compatibility alias for vulnerability Threat events.

Summary query params: days defaults to 7.

Example summary response:

{
  "days": 7,
  "total": 21,
  "asset_discovered": 10,
  "service_discovered": 4,
  "webapp_discovered": 5,
  "url_discovered": 0,
  "path_discovered": 0,
  "certificate_discovered": 0,
  "vulnerability_discovered": 2,
  "file_collected": 0,
  "critical": 1,
  "high": 3
}

Exposure changes are read-only through the public API. They are generated internally when new assets, vulnerability Threats, vulnerability status transitions, and file artifacts are persisted.

Scan profile administration

GET /api/v1/admin/scan-profiles returns built-in config-backed profiles and DB-backed custom profiles with metadata such as source, built-in flag, enabled state, plugin count, and supported scope types.

POST /api/v1/admin/scan-profiles creates a global custom profile:

{
  "id": "custom_web",
  "name": "Custom Web",
  "description": "HTTP discovery and nuclei",
  "plugins": ["subfinder", "dnsx", "httpx", "nuclei"],
  "enabled": true
}

Custom profile IDs cannot override config-backed profile IDs.

Plugin catalog and settings

GET /api/v1/plugins returns registry metadata for enabled and disabled plugins, including supported asset types, supported execution modes, profile usage, and option schema.

PUT /api/v1/admin/plugins/{plugin_id}/settings validates submitted JSON against the plugin option schema before saving. The MVP does not support arbitrary CLI arguments or secret storage.

Threat Discussions

Method Path Description
GET /threats/{id}/comments List comments for a Threat. Clients receive only client-visible comments.
POST /threats/{id}/comments Create a Threat comment.
PATCH /threats/{id}/comments/{comment_id} Edit your own Threat comment.
GET /organizations/{id}/mentionable-users Search users that may be mentioned in an organization context.

Compatibility: /vulnerabilities/{id}/comments routes remain available as aliases.

Create/update comment body:

{
  "body": "Please review this with @alex.",
  "visible_to_client": false,
  "mentioned_user_ids": ["<user-uuid>"]
}

Admin and hacker users may choose visible_to_client. Client-created comments are forced client-visible by the backend.

Profile Notifications

Method Path Description
GET /me/notifications List the current user's notifications.
POST /me/notifications/{id}/read Mark one notification read.
POST /me/notifications/read-all Mark all current-user notifications read.

Notification listing supports page and page_size with a default page size of 20 and maximum of 100.