Partner Portal API v1

The Partner Portal API provides programmatic access to your Holm Security portal data. It supports MSSP reports and reseller reports with product usage data, plus customer engagement metrics and churn risk indicators. All responses are in JSON. Dates use YYYY-MM-DD format in the Europe/Stockholm timezone.

Base URL

https://portal-api.holmsecurity.com/v1

Content type

All requests and responses use application/json; charset=utf-8.

Products

The API uses short product codes to identify Holm Security products:

CodeProduct
SNSSystem & Network Security
DADevice Agent (System & Network Security - Computers)
WASWeb Application Security
CSCloud Security (CSPM)
PATPhishing Simulation & Awareness Training

Quick start

Get up and running in minutes. Follow these steps to make your first API call.

1

Enable API access

Log in to the Partner Portal as an admin and go to Settings → API.

If API access has not been enabled yet, click Enable API access. This creates your Organization key - a one-time action that reveals the full API management interface.

Your Organization key (format hsp_org_...) is always visible in the portal and identifies your organization when authenticating.

2

Create an API key

Click + Create API key and fill in the form:

  • Key name - a label to identify this key (e.g. "Production", "Staging")
  • Portal - select which partnership this key grants access to. Each key is scoped to one partnership.
  • Scopes - the report scope (mssp-report:read or reseller-report:read) is assigned automatically based on your partnership type. Optionally enable customers:read for customer engagement and churn risk data.
  • Allowed origins (recommended) - restrict which IP addresses or domains can use this key. Click My IP to auto-detect your current IP.
Copy your API key immediately. The API key (format hsp_...) is shown only once when created. It cannot be retrieved later - only the key prefix is visible in the keys table for identification.
3

Create a session

Use your key pair to create a session. The returned session token is used for all subsequent API calls:

curl -s -X POST https://portal-api.holmsecurity.com/v1/auth/session \
  -H "Content-Type: application/json" \
  -d '{"organization_key": "hsp_org_your_organization_key", "api_key": "hsp_your_api_key"}' | jq .
import requests

BASE_URL = "https://portal-api.holmsecurity.com/v1"

# Create a session with your key pair
session_resp = requests.post(f"{BASE_URL}/auth/session", json={
    "organization_key": "hsp_org_your_organization_key",
    "api_key": "hsp_your_api_key"
})
session = session_resp.json()
SESSION_TOKEN = session["session_token"]
print(f"Session created, expires at: {session['expires_at']}")

# Use session token for all subsequent requests
headers = {"Authorization": f"Session {SESSION_TOKEN}"}
const BASE_URL = "https://portal-api.holmsecurity.com/v1";

// Create a session with your key pair
const sessionRes = await fetch(`${BASE_URL}/auth/session`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    organization_key: "hsp_org_your_organization_key",
    api_key: "hsp_your_api_key"
  })
});
const session = await sessionRes.json();
const SESSION_TOKEN = session.session_token;
console.log(`Session created, expires at: ${session.expires_at}`);

// Use session token for all subsequent requests
const headers = { "Authorization": `Session ${SESSION_TOKEN}` };
4

Fetch usage data

Start by listing available reporting periods, then drill into billing summary or daily data:

# List available reporting periods
curl -s "https://portal-api.holmsecurity.com/v1/mssp-report" \
  -H "Authorization: Session pps_your_session_token" | jq .

# Billing totals per product for February 2026
curl -s "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/usage/billing" \
  -H "Authorization: Session pps_your_session_token" | jq .

# Per-company billing breakdown
curl -s "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/usage/billing?group_by=company" \
  -H "Authorization: Session pps_your_session_token" | jq .

# Daily usage for a specific company
curl -s "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/companies/SE-ARNXXXX/usage?view=daily" \
  -H "Authorization: Session pps_your_session_token" | jq .
import requests, time

# headers already set from step 2: {"Authorization": f"Session {SESSION_TOKEN}"}

# List available reporting periods
resp = requests.get(f"{BASE_URL}/mssp-report", headers=headers)
periods = resp.json()["results"]
for p in periods:
    print(f"  {p['year']}-{p['period']} ({p['from']} to {p['to']})"
          f" {'(current)' if p['is_current'] else ''}")

# Respect rate limit (1 req/sec)
time.sleep(1)

# Billing totals for the latest completed period
period = next(p for p in periods if not p["is_partial"])
resp = requests.get(
    f"{BASE_URL}/mssp-report/{period['year']}/{period['period']}/usage/billing",
    headers=headers)
data = resp.json()
for total in data["totals"]:
    print(f"  {total['product']}: billing total = {total['billing_total']}")
// headers already set from step 2: { "Authorization": `Session ${SESSION_TOKEN}` }

// Helper with rate-limit handling
async function apiGet(path) {
  const res = await fetch(`${BASE_URL}${path}`, { headers });
  if (res.status === 429) {
    const retryMs = (await res.json()).retry_after_ms || 1000;
    await new Promise(r => setTimeout(r, retryMs));
    return apiGet(path);
  }
  return res.json();
}

// List available periods
const { results: periods } = await apiGet("/mssp-report");
periods.forEach(p =>
  console.log(`  ${p.year}-${p.period} (${p.from} to ${p.to})`)
);

// Billing totals for the latest completed period
const period = periods.find(p => !p.is_partial);
const summary = await apiGet(
  `/mssp-report/${period.year}/${period.period}/usage/billing`
);
summary.totals.forEach(t =>
  console.log(`  ${t.product}: billing total = ${t.billing_total}`)
);
Tip: The API is rate-limited to 1 request per second. Add a 1-second delay between calls, or handle 429 responses with the retry_after_ms value.
Need more help? See the knowledge base article for a detailed walkthrough of the API setup and usage, including screenshots and step-by-step instructions.

Common workflows

Generate a monthly billing report

Fetch the billing summary for the previous reporting period to calculate billing totals:

# 1. Check which periods are available
curl -s https://portal-api.holmsecurity.com/v1/mssp-report \
  -H "Authorization: Session $SESSION_TOKEN" | jq .

# 2. Get billing totals per product for the previous period (e.g. 2026-01)
curl -s "https://portal-api.holmsecurity.com/v1/mssp-report/2026/01/usage/billing" \
  -H "Authorization: Session $SESSION_TOKEN" | jq .

# 3. Get per-company billing values + totals in one call
curl -s "https://portal-api.holmsecurity.com/v1/mssp-report/2026/01/usage/billing?group_by=company,product" \
  -H "Authorization: Session $SESSION_TOKEN" | jq .
import requests, time

BASE_URL = "https://portal-api.holmsecurity.com/v1"
SESSION_TOKEN = "pps_your_session_token"  # from POST /v1/auth/session
headers = {"Authorization": f"Session {SESSION_TOKEN}"}

# 1. Find available periods
periods = requests.get(f"{BASE_URL}/mssp-report",
                       headers=headers).json()["results"]
# Pick the most recent completed (non-partial) period
bp = next(p for p in periods if not p["is_partial"])
print(f"Billing period: {bp['year']}-{bp['period']}")
print(f"  Range: {bp['from']} to {bp['to']}")

time.sleep(1)  # respect rate limit

# 2. Fetch billing summary with both totals and per-company breakdown
resp = requests.get(
    f"{BASE_URL}/mssp-report/{bp['year']}/{bp['period']}/usage/billing",
    headers=headers, params={"group_by": "company,product"})
data = resp.json()

# 3. Print billing summary
print("\nBilling Summary:")
for total in data["totals"]:
    print(f"  {total['product']}: "
          f"billing total = {total['billing_total']} "
          f"across {total['company_count']} companies")

# 4. Per-company breakdown
print("\nPer-company billing:")
for company in data["results"]:
    for entry in company["billing"]:
        val = entry["billing_value"] if entry["billing_value"] is not None else "N/A"
        print(f"  {company['company_name']} ({company['security_center_id']}): "
              f"{entry['product']} = {val}")

Paginate through all companies in a period

Use limit and offset to iterate through large result sets:

import requests, time

def get_all_companies(base_url, headers, year, period):
    """Fetch all companies for a reporting period, handling pagination and rate limits."""
    companies = []
    offset = 0
    limit = 100

    while True:
        resp = requests.get(
            f"{base_url}/mssp-report/{year}/{period}/companies",
            headers=headers, params={"limit": limit, "offset": offset})

        if resp.status_code == 429:
            retry_ms = resp.json().get("retry_after_ms", 1000)
            time.sleep(retry_ms / 1000)
            continue

        data = resp.json()
        companies.extend(data["results"])

        if data["next"] is None:
            break

        offset += limit
        time.sleep(1)  # respect rate limit

    return companies

companies = get_all_companies(BASE_URL, headers, 2026, "01")
print(f"Total companies: {len(companies)}")
async function getAllCompanies(baseUrl, headers, year, period) {
  const companies = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const url = `${baseUrl}/mssp-report/${year}/${period}/companies?limit=${limit}&offset=${offset}`;
    const res = await fetch(url, { headers });

    if (res.status === 429) {
      const { retry_after_ms } = await res.json();
      await new Promise(r => setTimeout(r, retry_after_ms || 1000));
      continue;
    }

    const data = await res.json();
    companies.push(...data.results);

    if (!data.next) break;
    offset += limit;
    await new Promise(r => setTimeout(r, 1000)); // rate limit
  }

  return companies;
}

const companies = await getAllCompanies(BASE_URL, headers, 2026, "01");
console.log(`Total companies: ${companies.length}`);

Export daily usage to CSV

Pull daily usage for a period and write it to a CSV file. The full usage endpoint paginates at company level, returning billing values and daily rows per company:

import requests, csv, time

BASE_URL = "https://portal-api.holmsecurity.com/v1"
SESSION_TOKEN = "pps_your_session_token"  # from POST /v1/auth/session
headers = {"Authorization": f"Session {SESSION_TOKEN}"}

# Fetch full usage dump for January 2026 (paginated by company)
all_rows = []
offset = 0

while True:
    resp = requests.get(
        f"{BASE_URL}/mssp-report/2026/01/usage",
        headers=headers, params={"limit": 100, "offset": offset})

    if resp.status_code == 429:
        time.sleep(resp.json().get("retry_after_ms", 1000) / 1000)
        continue

    data = resp.json()

    # Each result is a company with billing + daily arrays
    for company in data["results"]:
        for row in company["daily"]:
            all_rows.append({
                "date": row["date"],
                "security_center_id": company["security_center_id"],
                "company_name": company["company_name"],
                "product": row["product"],
                "usage_value": row["usage_value"]
            })

    if data["next"] is None:
        break
    offset += 100
    time.sleep(1)

# Write to CSV
with open("usage_2026_01.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "date", "security_center_id", "company_name",
        "product", "usage_value"
    ])
    writer.writeheader()
    writer.writerows(all_rows)

print(f"Exported {len(all_rows)} rows to usage_2026_01.csv")

Authentication

The API uses a key pair + session authentication system. Partners authenticate with two keys to create a short-lived session, then use the session token for all API calls.

Key pair

Each partner organization receives two keys from the Partner Portal settings:

KeyFormatDescription
Organization keyhsp_org_...Always visible in portal settings. Identifies your organization.
API keyhsp_...Shown only once when generated. Used together with the organization key to create sessions.

Authentication flow

  1. Send a POST /v1/auth/session request with your organization_key and api_key in the request body.
  2. Receive a session token (format pps_...) in the response.
  3. Include the session token in all subsequent API requests using the Authorization header:
Authorization: Session <session-token>

Session properties

Origin restrictions

API keys can optionally have allowed origins (IP addresses or domains) configured in portal settings. When configured, session creation requests are only accepted from those origins. Sessions are always locked to the IP that created them, regardless of whether allowed origins are configured.

Scopes

Sessions inherit the scopes assigned to the API key. A session may have multiple scopes.

ScopeDescription
me:readRead identity endpoint
mssp-report:readMSSP report: reporting periods, companies, and usage data
reseller-report:readReseller report: calendar month periods (1st→last), companies, and usage data
customers:readCustomer listing, engagement metrics, and churn risk indicators

Scope isolation - MSSP vs. Reseller

MSSP and reseller reports are mutually exclusive. An API key created for an MSSP partnership has the mssp-report:read scope and can only access /v1/mssp-report endpoints. An API key created for a reseller partnership has the reseller-report:read scope and can only access /v1/reseller-report endpoints. Attempting to access the wrong report type returns 403 Forbidden.

Each API key is bound to a specific partnership at creation time. If your organization has multiple partnerships (e.g. one MSSP partnership and one reseller partnership), you need a separate API key for each. Each key will have its own organization key, session, and scopes matching that partnership's report type.

The customers:read scope works with both partnership types - customer endpoints automatically use the correct eligibility logic (MSSP or reseller) based on the API key's partnership.

Error responses

401 Unauthorized - Missing, invalid, or expired session token

{
  "description": "Authentication credentials were not provided. Use Authorization: Session <token>"
}

403 Forbidden - Valid session but missing required scope, IP mismatch, or company not accessible

{
  "description": "Permission denied",
  "errors": {
    "scope": ["Missing required scope: mssp-report:read"]
  }
}

409 Conflict - Maximum sessions exceeded (when creating a new session)

{
  "description": "Maximum of 5 active sessions per organization. Please invalidate an existing session (DELETE /v1/auth/session) or wait for one to expire.",
  "active_sessions": 5,
  "max_sessions": 5
}
POST /v1/auth/session Create a new session

Authentication

No session token required. Uses organization key and API key in the request body.

Request body

FieldTypeRequiredDescription
organization_keystringYesYour Organization key (format hsp_org_...)
api_keystringYesYour API key (format hsp_...)

Example request

curl -X POST https://portal-api.holmsecurity.com/v1/auth/session \
  -H "Content-Type: application/json" \
  -d '{
    "organization_key": "hsp_org_your_organization_key",
    "api_key": "hsp_your_api_key"
  }'

Response 201 Created

{
  "session_token": "pps_abc123def456...",
  "expires_at": "2026-03-11T15:30:00Z",
  "valid_for_seconds": 3600,
  "scopes": ["me:read", "mssp-report:read"],
  "locked_to_origin": "203.0.113.42",
  "message": "Use this session token in the Authorization header: Session <token>. This session is locked to origin 203.0.113.42."
}

If the request origin could not be determined, the response includes an origin_warning field instead of the locked origin message:

{
  ...
  "locked_to_origin": null,
  "message": "Use this session token in the Authorization header: Session <token>.",
  "origin_warning": "Could not determine your request origin. This session is NOT locked to an IP and is therefore less secure. We strongly recommend configuring allowed origins on your API key in the partner portal."
}

Error responses

401 Unauthorized - Invalid Organization key or API key

403 Forbidden - Request origin not in allowed origins list

409 Conflict - Maximum 5 active sessions per organization reached

POST /v1/auth/session/validate Check if a session is valid

Authentication

Accepts session token via Authorization: Session <token> header or session_token in the request body.

Example request (header)

curl -X POST https://portal-api.holmsecurity.com/v1/auth/session/validate \
  -H "Authorization: Session pps_your_session_token"

Example request (body)

curl -X POST https://portal-api.holmsecurity.com/v1/auth/session/validate \
  -H "Content-Type: application/json" \
  -d '{"session_token": "pps_your_session_token"}'

Response 200 OK - Valid session

{
  "valid": true,
  "expires_at": "2026-03-11T15:30:00Z",
  "remaining_seconds": 2847,
  "scopes": ["me:read", "mssp-report:read"],
  "locked_to_origin": "203.0.113.42",
  "created_at": "2026-03-11T14:30:00Z"
}

Response 200 OK - Expired session

{
  "valid": false,
  "reason": "Session has expired.",
  "expired_at": "2026-03-11T15:30:00Z"
}

Response 200 OK - Not found

{
  "valid": false,
  "reason": "Session token not found."
}

Error responses

400 Bad Request - No session token provided in header or body

DELETE /v1/auth/session Invalidate a session

Authentication

Requires Authorization: Session <token> header.

Example request

curl -X DELETE https://portal-api.holmsecurity.com/v1/auth/session \
  -H "Authorization: Session pps_your_session_token"

Response 200 OK

{
  "success": true,
  "message": "Session invalidated successfully."
}

Error responses

401 Unauthorized - No session token provided or token is empty

404 Not Found - Session not found or already expired

Rate limiting

The API enforces a limit of 1 request per second per session.

If exceeded, the API returns HTTP 429 Too Many Requests:

{
  "description": "Rate limit exceeded",
  "retry_after_ms": 750
}

Response headers include:

HeaderDescription
Retry-AfterSeconds to wait (integer)
X-Retry-After-MsMilliseconds to wait
X-RateLimit-LimitAlways 1
X-RateLimit-Remaining0 when limited
Wait retry_after_ms then retry. Use exponential backoff if repeated 429s occur.

Error format

All error responses follow a consistent structure:

{
  "description": "Request failed",
  "errors": {
    "field": ["message"]
  }
}

Key lifecycle

API keys and the organization key are managed through the partner portal settings UI. The lifecycle is:

Key rotation (recommended every 6 months)

API keys do not expire, but we recommend rotating them every 6 months for security. The API key list includes a rotationDue flag and a rotationMessage for keys that haven't been rotated in over 6 months - the portal UI should display a warning when this flag is set.

Rotating a key generates a new key with the same name, scopes, allowed origins, and target portal. The old key is automatically revoked and its active sessions are invalidated. The new key is shown once and must be stored securely.

Rotation is seamless: the new key inherits all configuration from the old key. Consumers only need to update the API key value in their integration - no scope or origin changes required.

Key revocation

Revoking a key permanently disables it. All active sessions created with the key are immediately invalidated. Revoked keys remain visible in the key list for audit purposes but cannot be used to create new sessions.

Disabling API access (Organization key deletion)

If a partner no longer wants API functionality, the organization key can be deleted. This cascade-deletes all API keys (active and revoked) and all active sessions for the organization. The action is irreversible - the partner would need to create a new organization key from scratch to re-enable API access.

Irreversible: Deleting the organization key removes all API keys and sessions permanently. Any external integrations using API keys from this organization will immediately stop working.
GET /v1/me Partner identity, API key details, and scopes

Required scope

me:read

Example request

curl https://portal-api.holmsecurity.com/v1/me \
  -H "Authorization: Session $SESSION_TOKEN"

Response

{
  "partner_name": "Slate Rock and Gravel Co.",
  "organization_key": "hsp_org_abc123def456...",
  "api_key_name": "General Access",
  "api_key_prefix": "hsp_a1b2c3d4",
  "scopes": ["me:read", "mssp-report:read"],
  "allowed_origins": ["203.0.113.10"],
  "timezone": "Europe/Stockholm"
}

MSSP report

Usage reporting for MSSP partners. The billing period boundaries used by your organization are returned in the from and to fields on every report endpoint - call GET /v1/mssp-report to retrieve the list of available periods with their exact dates. Companies are filtered by security center status and archived history. Reseller companies are excluded.

Required scope: mssp-report:read - only available on API keys created for an MSSP partnership. Reseller API keys cannot access these endpoints.

GET /v1/mssp-report List available reporting periods

Required scope

mssp-report:read

Reporting period definition

The reporting period boundaries (from / to) returned by every MSSP endpoint reflect the period configuration for your organization. The period always covers a full month - exact boundaries are returned in the response. For the current period, to is capped at the latest available processed date - the most recent date included in any report is two days ago (T−2).

Eligible companies

The set of companies included in any report is determined per period: companies that were active and not archived during the period are included; resellers within your organizer are excluded. The eligible_company_count field on each response tells you how many companies were included.

Example request

curl https://portal-api.holmsecurity.com/v1/mssp-report \
  -H "Authorization: Session $SESSION_TOKEN"

Response

{
  "timezone": "Europe/Stockholm",
  "results": [
    {
      "year": 2026,
      "period": "03",
      "from": "2026-02-26",
      "to": "2026-03-10",
      "is_current": true,
      "is_partial": true,
      "url": "/v1/mssp-report/2026/03"
    },
    {
      "year": 2026,
      "period": "02",
      "from": "2026-01-26",
      "to": "2026-02-25",
      "is_current": false,
      "is_partial": false,
      "url": "/v1/mssp-report/2026/02"
    }
  ]
}
Up to 6 reporting periods are available (current + 5 previous), but only periods that contain data are returned. If your organization started recently, fewer periods will be listed. Requesting periods outside this range returns 400.
GET /v1/mssp-report/{year}/{period} Reporting period detail with summary

Required scope

mssp-report:read

Example request

curl https://portal-api.holmsecurity.com/v1/mssp-report/2026/02 \
  -H "Authorization: Session $SESSION_TOKEN"

Response

{
  "timezone": "Europe/Stockholm",
  "year": 2026,
  "period": "02",
  "from": "2026-01-26",
  "to": "2026-02-25",
  "is_current": false,
  "is_partial": false,
  "eligible_company_count": 40,
  "products": ["CS", "DA", "PAT", "SNS", "WAS"],
  "links": {
    "companies": "/v1/mssp-report/2026/02/companies",
    "billing": "/v1/mssp-report/2026/02/usage/billing",
    "usage": "/v1/mssp-report/2026/02/usage"
  }
}
GET /v1/mssp-report/{year}/{period}/companies Eligible companies in a reporting period

Required scope

mssp-report:read

Query parameters

NameTypeDescription
searchstringFilter by company name or security_center_id
limitintegerPagination limit (default 100, max 1000)
offsetintegerPagination offset (default 0)

Example request

curl https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/companies \
  -H "Authorization: Session $SESSION_TOKEN"

Response

{
  "reporting_period": {
    "year": 2026, "period": "02",
    "from": "2026-01-26", "to": "2026-02-25",
    "is_partial": false
  },
  "count": 40,
  "next": null,
  "previous": null,
  "results": [
    {
      "security_center_id": "SE-ARN1001",
      "company_name": "Bedrock Security Inc.",
      "status": "active",
      "products": ["SNS", "WAS"]
    }
  ]
}

Only companies that were active and not archived during the period are returned (resellers excluded).

GET /v1/mssp-report/{year}/{period}/companies/{security_center_id}/usage Single company usage in a period

Required scope

mssp-report:read

Query parameters

NameTypeDescription
viewstringbilling (default), daily, or all
productstringFilter by product code (e.g. SNS)
limitintegerPagination limit for daily rows (default 100, max 1000)
offsetintegerPagination offset for daily rows (default 0)

Example request

curl "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/companies/SE-ARN1001/usage?view=billing" \
  -H "Authorization: Session $SESSION_TOKEN"

Response (view=billing)

{
  "reporting_period": {
    "year": 2026, "period": "02",
    "from": "2026-01-26", "to": "2026-02-25",
    "is_partial": false
  },
  "company": {
    "security_center_id": "SE-ARN1001",
    "company_name": "Bedrock Security Inc."
  },
  "view": "billing",
  "usage": [
    { "product": "SNS", "billing_value": 142, "billing_date": "2026-02-15", "last_scan_date": "2026-02-22" },
    { "product": "WAS", "billing_value": 87, "billing_date": "2026-02-02", "last_scan_date": "2026-02-19" }
  ]
}

Each entry includes last_scan_date - the most recent date a scan/assessment for that product was recorded for the company within the period (null if no scans occurred). DA does not have scan data, so last_scan_date is always null for DA.

Response (view=daily)

Returns daily usage rows for the company, paginated.

Response (view=all)

Returns both billing array (with last_scan_date per entry) and daily object (paginated) in a single response.

Returns 403 if the company is not accessible, or 404 if the company was not active during this period.
GET /v1/mssp-report/{year}/{period}/usage/billing Billing summary (totals + per-company values)
Billing summary: Returns the per-product totals and per-company billing values used in MSSP billing for the requested period, without the underlying daily breakdown. The exact rule used to derive each per-company value is determined by your organization's report configuration - the response carries the actual values your billing is based on. Use /v1/mssp-report/{year}/{period}/usage if you also need the daily rows.

Required scope

mssp-report:read

Query parameters

NameTypeDescription
group_bystringproduct (default), company, or company,product
productstringFilter by product code (e.g. SNS)
limitintegerPagination limit (default 100, max 1000)
offsetintegerPagination offset (default 0)

Example requests

# Totals per product
curl "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/usage/billing" \
  -H "Authorization: Session $SESSION_TOKEN"

# Per-company breakdown
curl "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/usage/billing?group_by=company" \
  -H "Authorization: Session $SESSION_TOKEN"

# Both totals and per-company
curl "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/usage/billing?group_by=company,product" \
  -H "Authorization: Session $SESSION_TOKEN"

Response (group_by=product)

Totals per product - sum of per-company billing values across all eligible companies.

{
  "reporting_period": {
    "year": 2026, "period": "02",
    "from": "2026-01-26", "to": "2026-02-25",
    "is_partial": false
  },
  "group_by": "product",
  "eligible_company_count": 40,
  "totals": [
    { "product": "SNS", "billing_total": 2272, "company_count": 38, "null_company_count": 0 },
    { "product": "WAS", "billing_total": 2, "company_count": 1, "null_company_count": 0 },
    { "product": "PAT", "billing_total": 4121, "company_count": 35, "null_company_count": 0 }
  ]
}

Totals rule: billing_total = sum of per-company billing_value entries (nulls ignored). null_company_count = companies where the value is null (product not enabled).

Response (group_by=company)

Per-company billing breakdown, paginated.

{
  "reporting_period": { ... },
  "group_by": "company",
  "eligible_company_count": 40,
  "count": 40,
  "next": null,
  "previous": null,
  "results": [
    {
      "security_center_id": "SE-ARN1001",
      "company_name": "Bedrock Security Inc.",
      "billing": [
        { "product": "SNS", "billing_value": 142, "billing_date": "2026-02-15", "last_scan_date": "2026-02-22" }
      ]
    }
  ]
}

Each per-company entry in the billing array includes last_scan_date - the most recent date a scan/assessment for that product was recorded for the company within the period (null if no scans occurred). DA does not have scan data, so last_scan_date is always null for DA.

Response (group_by=company,product)

Both product totals AND per-company breakdown combined in one response. Per-company entries in the billing array include last_scan_date.

Null handling: When a product filter is set and a company does not have that product, the company is still represented with billing_value: null and null_reason: "product_not_enabled_for_company".
GET /v1/mssp-report/{year}/{period}/usage Full usage dump (billing + daily per company)
Billing source: MSSP billing is taken from each company's billing array - those are the per-product values used in billing for the requested period. The daily arrays are provided for reference only and are not summed for billing.

Required scope

mssp-report:read

Query parameters

NameTypeDescription
productstringFilter by product code (e.g. SNS)
limitintegerCompanies per page (default 100, max 1000)
offsetintegerCompany-level offset (default 0)

Example request

curl "https://portal-api.holmsecurity.com/v1/mssp-report/2026/02/usage" \
  -H "Authorization: Session $SESSION_TOKEN"

Response

{
  "reporting_period": {
    "year": 2026, "period": "02",
    "from": "2026-01-26", "to": "2026-02-25",
    "is_partial": false
  },
  "eligible_company_count": 40,
  "count": 40,
  "next": null,
  "previous": null,
  "results": [
    {
      "security_center_id": "SE-ARN1001",
      "company_name": "Bedrock Security Inc.",
      "billing": [
        { "product": "SNS", "billing_value": 142, "billing_date": "2026-02-15", "last_scan_date": "2026-02-22" }
      ],
      "daily": [
        { "product": "SNS", "date": "2026-01-26", "usage_value": 130 },
        { "product": "SNS", "date": "2026-01-27", "usage_value": 135 }
      ]
    }
  ]
}

Each per-company entry in the billing array includes last_scan_date (the most recent scan/assessment date for that product within the period, null if no scans). Pagination is at the company level - a single company's data is never split across pages. Use the product filter to reduce response size.

Reseller report

Usage reporting for reseller partners. Periods use calendar months (1st to last day of month). Companies are included if their order status is active - no per-month historical eligibility check, no reseller exclusion.

Each per-company entry in the billing array includes last_scan_date for SNS, WAS, CS and PAT - the most recent date a scan/assessment for that product was recorded for the company within the calendar month (null if no scans occurred). DA does not have scan data, so last_scan_date is always null for DA.

Required scope: reseller-report:read - only available on API keys created for a reseller partnership. MSSP API keys cannot access these endpoints.

GET /v1/reseller-report List available calendar month periods

Required scope

reseller-report:read

Period definition

Each period covers a calendar month: 1st to last day of the month. The most recent date included in any report is two days ago (T−2).

Response

{
  "timezone": "Europe/Stockholm",
  "report_type": "reseller",
  "results": [
    {
      "year": 2026,
      "period": "03",
      "from": "2026-03-01",
      "to": "2026-03-10",
      "is_current": true,
      "is_partial": true,
      "url": "/v1/reseller-report/2026/03"
    }
  ]
}
Up to 6 calendar months are available (current + 5 previous), but only months with data are returned.
GET /v1/reseller-report/{year}/{period} Calendar month detail with summary

Required scope

reseller-report:read

Response

{
  "timezone": "Europe/Stockholm",
  "report_type": "reseller",
  "year": 2026,
  "period": "02",
  "from": "2026-02-01",
  "to": "2026-02-28",
  "is_current": false,
  "is_partial": false,
  "eligible_company_count": 25,
  "products": ["CS", "PAT", "SNS", "WAS"],
  "links": {
    "companies": "/v1/reseller-report/2026/02/companies",
    "usage": "/v1/reseller-report/2026/02/usage"
  }
}
GET /v1/reseller-report/{year}/{period}/companies Companies with active order status

Required scope

reseller-report:read

Query parameters

NameTypeDescription
searchstringFilter by company name or security_center_id
limitintegerPagination limit (default 100, max 1000)
offsetintegerPagination offset (default 0)

Response

{
  "reporting_period": { "year": 2026, "period": "02", ... },
  "report_type": "reseller",
  "count": 25,
  "results": [
    {
      "security_center_id": "SE-ARN1001",
      "company_name": "Rubble Construction Ltd.",
      "order_status": "active",
      "products": ["SNS", "WAS"]
    }
  ]
}

Only companies with orderStatus === "active" are included. No per-month historical eligibility check.

GET /v1/reseller-report/{year}/{period}/companies/{security_center_id}/usage Single company usage in a calendar month

Required scope

reseller-report:read

Query parameters

NameTypeDescription
viewstringbilling (default), daily, or all
productstringFilter by product code (e.g. SNS)
limitintegerPagination limit for daily rows (default 100, max 1000)
offsetintegerPagination offset for daily rows (default 0)

Response (view=billing)

{
  "reporting_period": { ... },
  "report_type": "reseller",
  "company": { "security_center_id": "SE-ARN1001", "company_name": "Rubble Construction Ltd." },
  "view": "billing",
  "usage": [
    { "product": "SNS", "billing_value": 95, "billing_date": "2026-02-12", "last_scan_date": "2026-02-19" }
  ]
}
GET /v1/reseller-report/{year}/{period}/usage Full usage dump (billing + daily per company)
Billing source: Reseller billing is calculated from the actual daily usage in the daily arrays returned by this endpoint - not from the per-company billing values. The billing array entries are included for reference only.

Required scope

reseller-report:read

Query parameters

NameTypeDescription
productstringFilter by product code
limitintegerCompanies per page (default 100, max 1000)
offsetintegerCompany-level offset (default 0)

Response

{
  "reporting_period": { ... },
  "report_type": "reseller",
  "eligible_company_count": 25,
  "count": 25,
  "results": [
    {
      "security_center_id": "SE-ARN1001",
      "company_name": "Rubble Construction Ltd.",
      "billing": [
        { "product": "SNS", "billing_value": 95, "billing_date": "2026-02-12", "last_scan_date": "2026-02-19" }
      ],
      "daily": [
        { "product": "SNS", "date": "2026-02-01", "usage_value": 88 }
      ]
    }
  ]
}

Customers

Customer endpoints provide access to active customers with engagement metrics and churn risk indicators.

Required scope: customers:read

The session's report scope determines whether MSSP or reseller eligibility logic is used: if the API key has mssp-report:read, the partner is an MSSP partner; if it has reseller-report:read, they are a reseller partner.

Active customer definition

Date range parameters

The from and to query parameters control the date range. Defaults to the last 30 days; maximum range is 12 months.

Data freshness

The most recent data available from the customer endpoints is from yesterday (T−1). Today's activity is not yet available.

GET /v1/customers List active customers

Required scope

customers:read

Query parameters

NameTypeDescription
searchstringFilter by company name or security_center_id
limitintegerPagination limit (default 100, max 1000)
offsetintegerPagination offset (default 0)

Response

{
  "count": 42,
  "next": "/v1/customers?limit=100&offset=100",
  "previous": null,
  "results": [
    {
      "security_center_id": "SE-ARN1001",
      "company_name": "Rubble Construction Ltd.",
      "products": ["SNS", "WAS"],
      "url": "/v1/customers/SE-ARN1001"
    }
  ]
}
GET /v1/customers/{security_center_id} Customer detail with links

Required scope

customers:read

Response

{
  "security_center_id": "SE-ARN1001",
  "company_name": "Rubble Construction Ltd.",
  "products": ["SNS", "WAS"],
  "links": {
    "engagement": "/v1/customers/SE-ARN1001/engagement",
    "churn_risk": "/v1/customers/SE-ARN1001/churn-risk"
  }
}

Error responses

403 Forbidden - Company not accessible with this session

{
  "description": "Permission denied",
  "errors": {
    "security_center_id": ["Company not accessible with this session"]
  }
}

404 Not Found - Company not found or not active

{
  "description": "Company not found or not active.",
  "errors": {
    "security_center_id": ["Company is not currently active"]
  }
}
GET /v1/customers/{security_center_id}/engagement Customer engagement metrics

Required scope

customers:read

Query parameters

NameTypeDescription
fromstringStart date YYYY-MM-DD (default: 30 days ago)
tostringEnd date YYYY-MM-DD (default: today)
typestringComma-separated metric types: scan_activity, scanner_health, api_usage

Response

{
  "company": { "security_center_id": "SE-ARN1001", "company_name": "Rubble Construction Ltd." },
  "from": "2026-02-10",
  "to": "2026-03-11",
  "metrics": {
    "scan_activity": [
      {
        "date": "2026-02-10",
        "sns_scans_succeeded": 12,
        "sns_scans_failed": 1,
        "was_scans_succeeded": 5,
        "was_scans_failed": 0,
        "cs_scans_succeeded": 3,
        "cs_scans_failed": 0
      }
    ],
    "scanner_health": [
      { "date": "2026-02-10", "probes_active": 4, "probes_inactive": 1 }
    ],
    "api_usage": [
      { "date": "2026-02-10", "api_calls": 87 }
    ]
  }
}

Only days with non-zero data are returned. The most recent date returned is yesterday (T−1).

GET /v1/customers/{security_center_id}/churn-risk Customer churn risk indicators

Required scope

customers:read

Query parameters

NameTypeDescription
fromstringStart date YYYY-MM-DD (default: 30 days ago)
tostringEnd date YYYY-MM-DD (default: today)
typestringComma-separated indicator types: usage_trend, scan_failures, inactive_scanners, zero_activity

Response

{
  "company": { "security_center_id": "SE-ARN1001", "company_name": "Rubble Construction Ltd." },
  "from": "2026-02-10",
  "to": "2026-03-11",
  "indicators": {
    "usage_trend": [
      { "product": "SNS", "date": "2026-02-10", "usage_value": 88 }
    ],
    "scan_failures": [
      { "date": "2026-02-10", "total_succeeded": 20, "total_failed": 3, "failure_ratio": 0.13 }
    ],
    "inactive_scanners": [
      { "date": "2026-02-10", "probes_active": 4, "probes_inactive": 1 }
    ],
    "zero_activity": [
      { "date": "2026-02-10", "has_scans": false, "has_api_calls": false }
    ]
  }
}

The most recent date returned is yesterday (T−1).

Scope requirements summary

EndpointMethodRequired Scopes
/v1/auth/sessionPOSTNone (uses key pair)
/v1/auth/session/validatePOSTValid session
/v1/auth/sessionDELETEValid session
/v1/meGETme:read
/v1/mssp-reportGETmssp-report:read
/v1/mssp-report/{y}/{p}GETmssp-report:read
/v1/mssp-report/{y}/{p}/companiesGETmssp-report:read
/v1/mssp-report/{y}/{p}/companies/{id}/usageGETmssp-report:read
/v1/mssp-report/{y}/{p}/usage/billingGETmssp-report:read
/v1/mssp-report/{y}/{p}/usageGETmssp-report:read
/v1/reseller-reportGETreseller-report:read
/v1/reseller-report/{y}/{p}GETreseller-report:read
/v1/reseller-report/{y}/{p}/companiesGETreseller-report:read
/v1/reseller-report/{y}/{p}/companies/{id}/usageGETreseller-report:read
/v1/reseller-report/{y}/{p}/usageGETreseller-report:read
/v1/customersGETcustomers:read
/v1/customers/{scid}GETcustomers:read
/v1/customers/{scid}/engagementGETcustomers:read
/v1/customers/{scid}/churn-riskGETcustomers:read