Abnormal Security API Patterns — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Abnormal Security API Patterns (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The Abnormal Security REST API provides programmatic access to threat detection, cases, abuse mailbox submissions, message analysis, and per-message remediation. This skill covers authentication, request patterns, pagination, filtering, rate limiting, error handling, and performance optimization.
The paths below are the ones this plugin's MCP server actually calls. The vendor's wider API surface is out of scope here — if a path is not listed, no tool reaches it.
Abnormal Security uses a static Bearer token for API authentication:
GET https://api.abnormalplatform.com/v1/threats
Authorization: Bearer YOUR_API_TOKEN
Accept: application/json| Field | Description |
|---|---|
| Type | Static API token (no expiry rotation required) |
| Format | Long alphanumeric string |
| Header | Authorization: Bearer <token> |
| Scope | Full API access (determined at token creation) |
export ABNORMAL_API_TOKEN="your-api-token"
export ABNORMAL_MCP_URL="https://conduit.wyre.ai/v1/abnormal-security/mcp"When used through the MCP Gateway, credentials are passed via the Authorization header:
{
"headers": {
"Authorization": "Bearer ${ABNORMAL_API_TOKEN}"
}
}The gateway forwards this header to the Abnormal Security MCP server, which uses it to authenticate with the Abnormal API.
| Environment | Base URL |
|---|---|
| Production | https://api.abnormalplatform.com |
| Service | Path | Backing tool |
|---|---|---|
| Threats | /v1/threats | abnormal_threats_list |
| Threat Details | /v1/threats/{threatId} | abnormal_threats_get, abnormal_messages_list |
| Cases | /v1/cases | abnormal_cases_list |
| Case Details | /v1/cases/{caseId} | abnormal_cases_get |
| Messages | /v1/threats/{threatId}/messages/{messageId} | abnormal_messages_get |
| Remediation | /v1/threats/{threatId}/messages/{messageId}/remediation | abnormal_remediation_manage — GET for status, POST for remediate/unremediate |
| Abuse Mailbox | /v1/abuse_mailbox | abnormal_abuse_list |
{threatId} is a UUID string; {caseId} is a number. They are the two identifier vocabularies in this API and they look alike in prose — a threat UUID on the /cases/ path is a type error, not a 404 you should retry.
GET /v1/threats?pageSize=25&pageNumber=1
Authorization: Bearer <token>
Accept: application/jsonGET /v1/threats?filter=attackType eq 'BEC'&pageSize=25
Authorization: Bearer <token>
Accept: application/json{
"threats": [
{
"threatId": "184def76-3c28-4e1b-9ef0-a5abc123def4",
"attackType": "BEC",
"attackStrategy": "Invoice/Payment Fraud",
"sentTime": "2026-03-25T14:30:00Z"
}
],
"pageNumber": 1,
"total": 142,
"nextPageNumber": 2
}Page-number based, not cursor based — GET /v1/threats?pageSize=25&pageNumber=1. Responses carry nextPageNumber (absent on the last page) and total, as in the response example above.
| Parameter | Type | Description | Default | Maximum |
|---|---|---|---|---|
pageSize | int | Results per page | 100 at the tool layer | 100 |
pageNumber | int | Page number (1-based) | 1 | - |
async function fetchAllPages(endpoint, params = {}) {
const allItems = [];
let pageNumber = 1;
const pageSize = 100;
let hasMore = true;
while (hasMore) {
const url = new URL(endpoint, 'https://api.abnormalplatform.com');
url.searchParams.set('pageSize', pageSize.toString());
url.searchParams.set('pageNumber', pageNumber.toString());
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
});
const data = await response.json();
const items = data.threats || data.cases || [];
allItems.push(...items);
hasMore = data.nextPageNumber != null;
pageNumber = data.nextPageNumber || pageNumber + 1;
}
return allItems;
}Abnormal Security supports OData-style filter expressions on list endpoints.
All three list tools — abnormal_threats_list, abnormal_cases_list, abnormal_abuse_list — expose this as one filter string parameter. There is no fromDate/toDate pair anywhere; date scoping goes inside the filter string, and the time field differs per endpoint: receivedTime for threats, createdTime for cases, firstReportedTime for abuse mailbox.
filter=<field> <operator> '<value>'| Operator | Description | Example |
|---|---|---|
eq | Equals | attackType eq 'BEC' |
ne | Not equals | status ne 'Closed' |
gt | Greater than | receivedTime gt 2026-03-01T00:00:00Z |
lt | Less than | createdTime lt 2026-03-27T00:00:00Z |
ge | Greater than or equal | sentTime ge '2026-03-01T00:00:00Z' |
le | Less than or equal | sentTime le '2026-03-27T00:00:00Z' |
and | Logical AND | attackType eq 'BEC' and severity eq 'Critical' |
or | Logical OR | attackType eq 'BEC' or attackType eq 'Phishing' |
GET /v1/threats?filter=sentTime ge '2026-03-20T00:00:00Z' and sentTime le '2026-03-27T00:00:00Z'GET /v1/threats?filter=attackType eq 'BEC' and remediationStatus eq 'Not Remediated'&pageSize=50| Limit Type | Value | Scope |
|---|---|---|
| Requests per minute | 60 | Per API token |
| Requests per hour | 1,000 | Per API token |
When rate limited, the API returns HTTP 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 60{
"error": "Rate limit exceeded. Please retry after 60 seconds."
}async function requestWithRetry(url, options, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
const jitter = Math.random() * 5000;
await sleep(retryAfter * 1000 + jitter);
continue;
}
if (response.status === 401) {
throw new Error('Invalid API token. Regenerate at Settings > Integrations > API.');
}
return response;
}
throw new Error('Max retries exceeded');
}| Code | Meaning | Action |
|---|---|---|
| 200 | Success | Process response |
| 400 | Bad Request | Check request format, filter syntax |
| 401 | Unauthorized | Check API token |
| 403 | Forbidden | Token lacks required permissions |
| 404 | Not Found | Entity does not exist |
| 429 | Rate Limited | Wait per Retry-After header |
| 500 | Server Error | Retry with exponential backoff |
| 503 | Service Unavailable | Temporary outage, retry later |
{
"error": "Invalid filter expression",
"message": "The field 'attackType' does not support the operator 'contains'.",
"statusCode": 400
}| Error | Scenario | Resolution |
|---|---|---|
| Invalid token | Token revoked or miscopied | Regenerate at Settings > Integrations > API |
| Invalid filter | Unsupported OData expression | Check filter syntax and supported operators |
| Entity not found | Threat/case ID does not exist | Verify the ID via list endpoint |
| Permission denied | Token scope insufficient | Generate new token with required permissions |
| Date range error | Dates in wrong format | Use ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ |
Push the narrowing into the filter argument of abnormal_threats_list, abnormal_cases_list, and abnormal_abuse_list rather than pulling a broad page and discarding most of it client-side. Each of the three takes an OData filter plus pageSize/pageNumber.
abnormal_threats_list, abnormal_cases_list, and abnormal_abuse_list are independent top-level collections and can be issued concurrently.
abnormal_messages_list, abnormal_messages_get, and abnormal_remediation_manage are not independent — all three are nested under a threat and need a threatId you obtained first, so they serialise behind a /threats call. Remediating a campaign is therefore inherently sequential: list the threat's messages, then one abnormal_remediation_manage call per message. At 60 requests/minute a wide campaign will hit the rate limit mid-loop, so record which message IDs succeeded rather than assuming the loop ran to completion — a half-remediated campaign looks identical to a finished one from the outside.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.