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:
| Code | Product |
|---|---|
SNS | System & Network Security |
DA | Device Agent (System & Network Security - Computers) |
WAS | Web Application Security |
CS | Cloud Security (CSPM) |
PAT | Phishing Simulation & Awareness Training |
Quick start
Get up and running in minutes. Follow these steps to make your first API call.
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.
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:readorreseller-report:read) is assigned automatically based on your partnership type. Optionally enablecustomers:readfor 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.
hsp_...) is shown only once when created. It cannot be retrieved later - only the key prefix is visible in the keys table for identification.
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}` };
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}`) );
429 responses with the retry_after_ms value.
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:
| Key | Format | Description |
|---|---|---|
| Organization key | hsp_org_... | Always visible in portal settings. Identifies your organization. |
| API key | hsp_... | Shown only once when generated. Used together with the organization key to create sessions. |
Authentication flow
- Send a
POST /v1/auth/sessionrequest with yourorganization_keyandapi_keyin the request body. - Receive a session token (format
pps_...) in the response. - Include the session token in all subsequent API requests using the
Authorizationheader:
Authorization: Session <session-token>
Session properties
- Sessions are valid for 1 hour by default.
- Maximum 5 active sessions per organization. Exceeding this limit returns
409 Conflict. - Sessions are locked to the IP address that created them (dynamic origin binding). Requests from a different IP will be rejected.
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.
| Scope | Description |
|---|---|
me:read | Read identity endpoint |
mssp-report:read | MSSP report: reporting periods, companies, and usage data |
reseller-report:read | Reseller report: calendar month periods (1st→last), companies, and usage data |
customers:read | Customer listing, engagement metrics, and churn risk indicators |
Scope isolation - MSSP vs. Reseller
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
}
Authentication
No session token required. Uses organization key and API key in the request body.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
organization_key | string | Yes | Your Organization key (format hsp_org_...) |
api_key | string | Yes | Your 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
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
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:
| Header | Description |
|---|---|
Retry-After | Seconds to wait (integer) |
X-Retry-After-Ms | Milliseconds to wait |
X-RateLimit-Limit | Always 1 |
X-RateLimit-Remaining | 0 when limited |
retry_after_ms then retry. Use exponential backoff if repeated 429s occur.
Pagination
List endpoints support offset-based pagination:
| Parameter | Default | Max |
|---|---|---|
limit | 100 | 1000 |
offset | 0 | - |
Response envelope
{
"count": 0,
"next": null,
"previous": null,
"results": []
}
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.
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.
Required scope
me:readExample 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.
Required scope
mssp-report:readReporting 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"
}
]
}
400.
Required scope
mssp-report:readExample 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"
}
}
Required scope
mssp-report:readQuery parameters
| Name | Type | Description |
|---|---|---|
search | string | Filter by company name or security_center_id |
limit | integer | Pagination limit (default 100, max 1000) |
offset | integer | Pagination 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).
Required scope
mssp-report:readQuery parameters
| Name | Type | Description |
|---|---|---|
view | string | billing (default), daily, or all |
product | string | Filter by product code (e.g. SNS) |
limit | integer | Pagination limit for daily rows (default 100, max 1000) |
offset | integer | Pagination 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.
/v1/mssp-report/{year}/{period}/usage if you also need the daily rows.
Required scope
mssp-report:readQuery parameters
| Name | Type | Description |
|---|---|---|
group_by | string | product (default), company, or company,product |
product | string | Filter by product code (e.g. SNS) |
limit | integer | Pagination limit (default 100, max 1000) |
offset | integer | Pagination 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.
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".
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:readQuery parameters
| Name | Type | Description |
|---|---|---|
product | string | Filter by product code (e.g. SNS) |
limit | integer | Companies per page (default 100, max 1000) |
offset | integer | Company-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.
Required scope
reseller-report:readPeriod 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"
}
]
}
Required scope
reseller-report:readResponse
{
"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"
}
}
Required scope
reseller-report:readQuery parameters
| Name | Type | Description |
|---|---|---|
search | string | Filter by company name or security_center_id |
limit | integer | Pagination limit (default 100, max 1000) |
offset | integer | Pagination 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.
Required scope
reseller-report:readQuery parameters
| Name | Type | Description |
|---|---|---|
view | string | billing (default), daily, or all |
product | string | Filter by product code (e.g. SNS) |
limit | integer | Pagination limit for daily rows (default 100, max 1000) |
offset | integer | Pagination 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" }
]
}
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:readQuery parameters
| Name | Type | Description |
|---|---|---|
product | string | Filter by product code |
limit | integer | Companies per page (default 100, max 1000) |
offset | integer | Company-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
- MSSP: active = Security Center is active and not archived (resellers excluded)
- Reseller: active =
orderStatus === "active"
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.
Required scope
customers:readQuery parameters
| Name | Type | Description |
|---|---|---|
search | string | Filter by company name or security_center_id |
limit | integer | Pagination limit (default 100, max 1000) |
offset | integer | Pagination 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"
}
]
}
Required scope
customers:readResponse
{
"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"]
}
}
Required scope
customers:readQuery parameters
| Name | Type | Description |
|---|---|---|
from | string | Start date YYYY-MM-DD (default: 30 days ago) |
to | string | End date YYYY-MM-DD (default: today) |
type | string | Comma-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).
Required scope
customers:readQuery parameters
| Name | Type | Description |
|---|---|---|
from | string | Start date YYYY-MM-DD (default: 30 days ago) |
to | string | End date YYYY-MM-DD (default: today) |
type | string | Comma-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
| Endpoint | Method | Required Scopes |
|---|---|---|
/v1/auth/session | POST | None (uses key pair) |
/v1/auth/session/validate | POST | Valid session |
/v1/auth/session | DELETE | Valid session |
/v1/me | GET | me:read |
/v1/mssp-report | GET | mssp-report:read |
/v1/mssp-report/{y}/{p} | GET | mssp-report:read |
/v1/mssp-report/{y}/{p}/companies | GET | mssp-report:read |
/v1/mssp-report/{y}/{p}/companies/{id}/usage | GET | mssp-report:read |
/v1/mssp-report/{y}/{p}/usage/billing | GET | mssp-report:read |
/v1/mssp-report/{y}/{p}/usage | GET | mssp-report:read |
/v1/reseller-report | GET | reseller-report:read |
/v1/reseller-report/{y}/{p} | GET | reseller-report:read |
/v1/reseller-report/{y}/{p}/companies | GET | reseller-report:read |
/v1/reseller-report/{y}/{p}/companies/{id}/usage | GET | reseller-report:read |
/v1/reseller-report/{y}/{p}/usage | GET | reseller-report:read |
/v1/customers | GET | customers:read |
/v1/customers/{scid} | GET | customers:read |
/v1/customers/{scid}/engagement | GET | customers:read |
/v1/customers/{scid}/churn-risk | GET | customers:read |