Partner Portal API v1
The Partner Portal API provides programmatic access to your Holm Security portal data.
Currently supports product usage, company management,
and reporting periods for MSSP partners. All responses are 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 |
WAS | Web Application Security |
PAT | Phishing & Awareness Training |
CS | Cloud Security |
DA | Device Agent (System & Network Security — Computers) |
Quick Start
Get up and running in minutes. Follow these steps to make your first API call.
Get your key pair
Log in to the Partner Portal, go to Settings. You will find two keys:
- Organizer Key (format
hsp_org_...) — always visible in the portal - API Key (format
hsp_...) — shown only once when generated. Click Generate API Key and copy it immediately.
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 '{"organizer_key": "hsp_org_your_organizer_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={ "organizer_key": "hsp_org_your_organizer_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({ organizer_key: "hsp_org_your_organizer_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}` };
List your companies
Retrieve the companies you manage. Each company is identified by its security_center_id which follows the format SE-ARNXXXX where XXXX is a 4-digit number.
curl -s https://portal-api.holmsecurity.com/v1/companies \ -H "Authorization: Session pps_your_session_token" | jq .
resp = requests.get(f"{BASE_URL}/companies", headers=headers)
companies = resp.json()["results"]
for c in companies:
print(f"{c['security_center_id']} {c['company_name']}")const res = await fetch(`${BASE_URL}/companies`, { headers });
const { results } = await res.json();
results.forEach(c => console.log(c.security_center_id, c.company_name));Fetch usage data
Get the latest product usage across all your companies, or for a specific reporting period:
# Latest usage for all companies, product SNS curl -s "https://portal-api.holmsecurity.com/v1/usage/product?mode=latest&product=SNS" \ -H "Authorization: Session pps_your_session_token" | jq . # Peak usage for reporting period January 2026 curl -s "https://portal-api.holmsecurity.com/v1/usage/product?mode=period&reporting_year=2026&reporting_period=01&view=peaks" \ -H "Authorization: Session pps_your_session_token" | jq . # Daily usage for a specific company curl -s "https://portal-api.holmsecurity.com/v1/usage/product?mode=period&reporting_year=2026&reporting_period=01&view=daily&security_center_id=SE-ARNXXXX" \ -H "Authorization: Session pps_your_session_token" | jq .
import requests, time # headers already set from step 2: {"Authorization": f"Session {SESSION_TOKEN}"} # Latest usage for all companies resp = requests.get(f"{BASE_URL}/usage/product", headers=headers, params={"mode": "latest", "product": "SNS"}) data = resp.json() print(f"Data as of: {data['as_of']}") for row in data["results"]: print(f" {row['security_center_id']}: {row['usage_value']}") # Respect rate limit (1 req/sec) time.sleep(1) # Peak usage for a reporting period resp = requests.get(f"{BASE_URL}/usage/product", headers=headers, params={"mode": "period", "reporting_year": 2026, "reporting_period": "01", "view": "peaks"}) peaks = resp.json() for total in peaks.get("totals", []): print(f" {total['product']}: peak sum = {total['total_peak_sum']}")
// headers already set from step 2: { "Authorization": `Session ${SESSION_TOKEN}` } // Helper with rate-limit handling async function apiGet(path, params = {}) { const url = new URL(`${BASE_URL}${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); const res = await fetch(url, { headers }); if (res.status === 429) { const retryMs = (await res.json()).retry_after_ms || 1000; await new Promise(r => setTimeout(r, retryMs)); return apiGet(path, params); // retry once } return res.json(); } // Latest usage const latest = await apiGet("/usage/product", { mode: "latest", product: "SNS" }); console.log(`Data as of: ${latest.as_of}`); latest.results.forEach(r => console.log(` ${r.security_center_id}: ${r.usage_value}`) ); // Peak usage for a period const peaks = await apiGet("/usage/product", { mode: "period", reporting_year: "2026", reporting_period: "01", view: "peaks" }); peaks.totals.forEach(t => console.log(` ${t.product}: peak sum = ${t.total_peak_sum}`) );
429 responses with the retry_after_ms value.
Common Workflows
Generate a monthly billing report
Fetch peak usage for the previous reporting period to calculate billing totals:
# 1. Check which periods are available curl -s https://portal-api.holmsecurity.com/v1/reporting-periods/available \ -H "Authorization: Session $SESSION_TOKEN" | jq . # 2. Get peaks for the previous period (e.g. 2026-01) curl -s "https://portal-api.holmsecurity.com/v1/usage/product?mode=period&reporting_year=2026&reporting_period=01&view=peaks" \ -H "Authorization: Session $SESSION_TOKEN" | jq . # The response includes "totals" with total_peak_sum per product
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}/reporting-periods/available", headers=headers).json()["results"] # Pick the most recent completed (non-partial) period billing_period = next(p for p in periods if not p["is_partial"]) print(f"Billing period: {billing_period['year']}-{billing_period['period']}") print(f" Range: {billing_period['from']} to {billing_period['to']}") time.sleep(1) # respect rate limit # 2. Fetch peak usage with totals resp = requests.get(f"{BASE_URL}/usage/product", headers=headers, params={ "mode": "period", "reporting_year": billing_period["year"], "reporting_period": billing_period["period"], "view": "peaks" }) data = resp.json() # 3. Print billing summary print("\nBilling Summary:") for total in data["totals"]: print(f" {total['product']} ({total['metric']}): " f"peak sum = {total['total_peak_sum']} " f"across {total['company_count']} companies") # 4. Per-company breakdown print("\nPer-company peaks:") for peak in data["peaks"]: val = peak["peak_value"] if peak["peak_value"] is not None else "N/A" print(f" {peak['company_name']} ({peak['security_center_id']}): " f"{peak['product']} = {val}")
Paginate through all companies
Use limit and offset to iterate through large result sets:
import requests, time
def get_all_companies(base_url, headers):
"""Fetch all companies, handling pagination and rate limits."""
companies = []
offset = 0
limit = 100
while True:
resp = requests.get(f"{base_url}/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)
print(f"Total companies: {len(companies)}")async function getAllCompanies(baseUrl, headers) {
const companies = [];
let offset = 0;
const limit = 100;
while (true) {
const url = `${baseUrl}/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);
console.log(`Total companies: ${companies.length}`);Export daily usage to CSV
Pull daily usage for a period and write it to a CSV file:
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 daily usage for January 2026 all_rows = [] offset = 0 while True: resp = requests.get(f"{BASE_URL}/usage/product", headers=headers, params={ "mode": "period", "reporting_year": 2026, "reporting_period": "01", "view": "daily", "limit": 1000, "offset": offset }) if resp.status_code == 429: time.sleep(resp.json().get("retry_after_ms", 1000) / 1000) continue data = resp.json() all_rows.extend(data["results"]) if data["next"] is None: break offset += 1000 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", "metric", "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 |
|---|---|---|
| Organizer Key | hsp_org_... | Always visible in portal settings. Identifies your organization. |
| API Key | hsp_... | Shown only once when generated. Used together with the organizer key to create sessions. |
Authentication Flow
- Send a
POST /v1/auth/sessionrequest with yourorganizer_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 |
companies:read | List and read companies accessible to the session |
products:read | List products, product coverage, and availability |
usage:read | Read usage endpoints (latest, period, peaks, daily) |
reporting-periods:read | Read reporting period helper endpoints |
Error Responses
401 Unauthorized — Missing, invalid, or expired session token
{
"description": "Authentication credentials were not provided."
}
403 Forbidden — Valid session but missing required scope, IP mismatch, or company not accessible
{
"description": "Permission denied",
"errors": {
"scope": ["Missing required scope: usage:read"]
}
}
409 Conflict — Maximum sessions exceeded (when creating a new session)
{
"description": "Session limit exceeded",
"errors": {
"session": ["Maximum of 5 active sessions per organization. Invalidate an existing session before creating a new one."]
}
}
Authentication
No session token required. Uses organizer key and API key in the request body.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
organizer_key | string | Yes | Your organizer 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 '{
"organizer_key": "hsp_org_your_organizer_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", "companies:read", "products:read", "usage:read", "reporting-periods:read"],
"locked_to_origin": "203.0.113.42",
"message": "Session created successfully"
}
Error Responses
401 Unauthorized — Invalid organizer key or API key
403 Forbidden — Request origin not in allowed origins list
409 Conflict — Maximum 5 active sessions per organization reached
Authentication
Requires Authorization: Session <token> header.
Example Request
curl -X POST https://portal-api.holmsecurity.com/v1/auth/session/validate \ -H "Authorization: Session pps_your_session_token"
Response 200 OK
{
"valid": true,
"expires_at": "2026-03-11T15:30:00Z",
"scopes": ["me:read", "companies:read", "products:read", "usage:read", "reporting-periods:read"],
"locked_to_origin": "203.0.113.42"
}
Error Responses
401 Unauthorized — Invalid or expired session token
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
{
"message": "Session invalidated successfully"
}
Error Responses
401 Unauthorized — Invalid or already expired session token
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"]
}
}
Required Scope
me:readExample Request
curl https://portal-api.holmsecurity.com/v1/me \ -H "Authorization: Session $SESSION_TOKEN"
Response
{
"partner_id": "p_9001",
"partner_name": "NordSec Solutions AB",
"scopes": ["me:read", "companies:read", "products:read", "usage:read", "reporting-periods:read"],
"timezone": "Europe/Stockholm"
}
Required Scope
companies:readExample Requests
# List all companies curl https://portal-api.holmsecurity.com/v1/companies \ -H "Authorization: Session $SESSION_TOKEN" # Search by name curl "https://portal-api.holmsecurity.com/v1/companies?search=acme" \ -H "Authorization: Session $SESSION_TOKEN" # Paginate (page 2, 50 per page) curl "https://portal-api.holmsecurity.com/v1/companies?limit=50&offset=50" \ -H "Authorization: Session $SESSION_TOKEN"
Query Parameters
| Name | Type | Description |
|---|---|---|
search | string | Filter by name or ID |
status | string | Filter by status |
limit | integer | Results per page (default 100, max 1000) |
offset | integer | Pagination offset (default 0) |
Response
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Teknikbolaget AB",
"status": "active"
},
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Nordic Cloud Oy",
"status": "active"
},
{
"security_center_id": "SE-ARNXXXX",
"company_name": "DataVakt AS",
"status": "active"
}
]
}
Required Scope
companies:readExample Request
curl https://portal-api.holmsecurity.com/v1/companies/SE-ARNXXXX \ -H "Authorization: Session $SESSION_TOKEN"
Path Parameters
| Name | Type | Description |
|---|---|---|
security_center_id | string | The company's Security Center ID |
Response
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Teknikbolaget AB",
"status": "active",
"created_at": "2024-03-01"
}
Required Scope
products:readExample Request
curl https://portal-api.holmsecurity.com/v1/products \ -H "Authorization: Session $SESSION_TOKEN"
Response
{
"results": [
{ "product": "SNS", "company_count": 120 },
{ "product": "WAS", "company_count": 98 },
{ "product": "PAT", "company_count": 85 },
{ "product": "CS", "company_count": 42 },
{ "product": "DA", "company_count": 67 }
]
}
Required Scopes
products:read companies:readExample Request
curl https://portal-api.holmsecurity.com/v1/companies/SE-ARNXXXX/products \ -H "Authorization: Session $SESSION_TOKEN"
Response
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Teknikbolaget AB",
"results": [
{ "product": "SNS" },
{ "product": "WAS" },
{ "product": "PAT" }
]
}
Required Scope
products:readExample Requests
# Summary (all products) curl https://portal-api.holmsecurity.com/v1/coverage/products \ -H "Authorization: Session $SESSION_TOKEN" # Detailed (specific product: which companies have it) curl "https://portal-api.holmsecurity.com/v1/coverage/products?product=SNS" \ -H "Authorization: Session $SESSION_TOKEN"
Query Parameters
| Name | Type | Description |
|---|---|---|
product | string | Filter by product code. When provided, returns detailed enabled/disabled lists. |
Summary Response
{
"results": [
{
"product": "SNS",
"enabled_company_count": 47,
"total_company_count": 52
}
]
}
Detailed Response (with product filter)
{
"product": "SNS",
"enabled_security_center_ids": ["SE-ARNXXXX", "SE-ARNXXXX", "SE-ARNXXXX"],
"disabled_security_center_ids": ["SE-ARNXXXX", "SE-ARNXXXX"]
}
Required Scope
reporting-periods:readExample Request
curl https://portal-api.holmsecurity.com/v1/reporting-periods/current \ -H "Authorization: Session $SESSION_TOKEN"
Reporting Period Definition
Period MM covers the 26th of the previous month through the 25th of month MM (inclusive).
For the current period, to is capped at the latest available processed date.
Response
{
"timezone": "Europe/Stockholm",
"current_period": {
"year": 2026,
"period": "02",
"from": "2026-01-26",
"to": "2026-02-23",
"is_partial": true
}
}
Required Scope
reporting-periods:readExample Request
curl https://portal-api.holmsecurity.com/v1/reporting-periods/available \ -H "Authorization: Session $SESSION_TOKEN"
Response
{
"results": [
{
"year": 2026,
"period": "02",
"from": "2026-01-26",
"to": "2026-02-23",
"is_partial": true
},
{
"year": 2026,
"period": "01",
"from": "2025-12-26",
"to": "2026-01-25",
"is_partial": false
},
{
"year": 2025,
"period": "12",
"from": "2025-11-26",
"to": "2025-12-25",
"is_partial": false
}
]
}
400.
Required Scope
usage:readExample Requests
# Latest usage for product SNS curl "https://portal-api.holmsecurity.com/v1/usage/product?mode=latest&product=SNS" \ -H "Authorization: Session $SESSION_TOKEN" # Period peaks for January 2026 curl "https://portal-api.holmsecurity.com/v1/usage/product?mode=period&reporting_year=2026&reporting_period=01&view=peaks" \ -H "Authorization: Session $SESSION_TOKEN" # Daily usage for a specific company curl "https://portal-api.holmsecurity.com/v1/usage/product?mode=period&reporting_year=2026&reporting_period=01&view=daily&security_center_id=SE-ARNXXXX" \ -H "Authorization: Session $SESSION_TOKEN" # Everything (peaks + daily) for all companies, all products curl "https://portal-api.holmsecurity.com/v1/usage/product?mode=period&reporting_year=2026&reporting_period=01&view=all" \ -H "Authorization: Session $SESSION_TOKEN"
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
mode | string | Yes | latest or period |
reporting_year | integer | If period | Year (e.g. 2026) |
reporting_period | string | If period | Month (e.g. 01) |
view | string | No | latest (default for latest mode), all | peaks | daily (for period mode) |
security_center_id | string | No | Filter to a single company (omit = all) |
product | string | No | Filter by product code (e.g. SNS) |
metric | string | No | Default: ips |
limit | integer | No | Pagination limit (default 100, max 1000) |
offset | integer | No | Pagination offset (default 0) |
Mode: latest
Returns the most recent usage values for each company.
{
"mode": "latest",
"view": "latest",
"timezone": "Europe/Stockholm",
"as_of": "2026-02-23",
"filters": {
"security_center_id": null,
"product": "SNS",
"metric": "ips"
},
"count": 3,
"next": null,
"previous": null,
"results": [
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Teknikbolaget AB",
"product": "SNS",
"metric": "ips",
"date": "2026-02-23",
"usage_value": 254
},
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Nordic Cloud Oy",
"product": "SNS",
"metric": "ips",
"date": "2026-02-23",
"usage_value": 128
},
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Berglund Fastigheter AB",
"product": "SNS",
"metric": "ips",
"date": "2026-02-23",
"usage_value": null,
"null_reason": "product_not_enabled_for_company"
}
]
}
Mode: period — view=peaks
Returns peak usage per company for a reporting period, plus totals.
{
"mode": "period",
"view": "peaks",
"timezone": "Europe/Stockholm",
"reporting_period": {
"year": 2026,
"period": "01",
"from": "2025-12-26",
"to": "2026-01-25",
"is_partial": false
},
"totals": [
{
"product": "SNS",
"metric": "ips",
"total_peak_sum": 648,
"company_count": 3,
"null_company_count": 0
}
],
"peaks": [
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Teknikbolaget AB",
"product": "SNS",
"metric": "ips",
"peak_value": 312,
"peak_date": "2026-01-14"
},
{
"security_center_id": "SE-ARNXXXX",
"company_name": "Nordic Cloud Oy",
"product": "SNS",
"metric": "ips",
"peak_value": 198,
"peak_date": "2026-01-08"
},
{
"security_center_id": "SE-ARNXXXX",
"company_name": "DataVakt AS",
"product": "SNS",
"metric": "ips",
"peak_value": 138,
"peak_date": "2026-01-20"
}
]
}
Mode: period — view=daily
Returns daily usage rows only (paginated). No peaks or totals.
Mode: period — view=all (default)
Returns both peaks and daily rows in a single response.
product filter is set and a company does not have that product,
the company is still represented with usage_value: null and
null_reason: "product_not_enabled_for_company".
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/companies | GET | companies:read |
/v1/companies/{id} | GET | companies:read |
/v1/products | GET | products:read |
/v1/companies/{id}/products | GET | products:read, companies:read |
/v1/coverage/products | GET | products:read |
/v1/reporting-periods/current | GET | reporting-periods:read |
/v1/reporting-periods/available | GET | reporting-periods:read |
/v1/usage/product | GET | usage:read |