Conventions
Request format, cursor pagination, errors, rate limits, idempotency and auditing in the v1 API.
These rules apply to every v1 API endpoint. Follow them once in your HTTP client and all your integrations get simpler.
Requests and responses
- JSON in
snake_case. Requests with a body (POSTandPATCH) need theContent-Type: application/jsonheader, and the body must be a JSON object. Otherwise, the response is400 invalid_request. - Unknown fields are rejected. A field the endpoint doesn't accept returns
400 invalid_requestwith the messageUnexpected field "field_name". This keeps a typo from going unnoticed. - Dates are ISO-8601 strings in UTC, such as
2026-10-06T12:00:00.000Z. In filters (since,period_start,period_end), send them in UTC too. - IDs are UUIDs, such as
0c1d2e3f-1111-4222-8333-444455556666. Anidin the wrong format returns400. - Successful responses include
Cache-Control: no-store. Creates respond201; all other operations respond200.
Pagination
Lists use cursor pagination. Send limit (from 1 to 100, default 50) and, for subsequent pages, the cursor you
received:
curl -s "https://app.proteside.com/api/v1/alerts?status=open&limit=100" \
-H "Authorization: Bearer $PROTESIDE_TOKEN"{
"data": [{ "id": "e1f2a3b4-8888-4999-8000-111122223333", "type": "PIX_TAMPERED", "...": "..." }],
"next_cursor": "WyIyMDI2LTEwLTA2VDA5OjQwOjAwLjAwMFoiLCJlMWYyYTNiNC04ODg4LTQ5OTktODAwMC0xMTExMjIyMjMzMzMiXQ"
}When next_cursor is null, there are no more pages. Treat the cursor as an opaque value: don't build or modify its
contents. An invalid cursor returns 400 Invalid cursor.
Items come from newest to oldest (by creation date; in GET /scripts, by the date the script was first seen). Two
lists aren't paginated and return everything at once, with no next_cursor: GET /stores/{id}/pages and
GET /reports/pci/schedule.
const BASE = 'https://app.proteside.com/api/v1';
async function listAll(path, params = {}) {
const items = [];
let cursor = null;
do {
const qs = new URLSearchParams({ ...params, limit: '100' });
if (cursor) qs.set('cursor', cursor);
const res = await fetch(`${BASE}${path}?${qs}`, {
headers: { Authorization: `Bearer ${process.env.PROTESIDE_TOKEN}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const page = await res.json();
items.push(...page.data);
cursor = page.next_cursor;
} while (cursor);
return items;
}
const pending = await listAll('/scripts', { status: 'needs_review' });
console.log(`${pending.length} scripts awaiting review`);Errors
Every error comes in the same format, with a stable code in error and a message in English in message:
{ "error": "invalid_request", "message": "justification must have at least 10 characters" }Use error in your logic; message is meant for logs and may change. Some errors include extra fields, such as
limit and plan in plan_limit.
error | HTTP | When it happens | What to do |
|---|---|---|---|
invalid_request | 400 | Malformed body, unknown field, out-of-range value, invalid cursor or limit | Fix the request; don't retry it unchanged |
unauthorized | 401 | Token missing, malformed, revoked or expired | Check the token; create another one if it was revoked or expired |
insufficient_scope | 403 | The token doesn't have the endpoint's scope, or tried to act on a child without orgs:manage | Create a token with the scope listed in the scopes table |
plan_limit | 403 | The plan's store limit was reached, or the PCI DSS report isn't included in the plan | Upgrade the plan or suspend a store you no longer use (the API doesn't reactivate suspended stores) |
not_found | 404 | The resource doesn't exist or doesn't belong to the organization you're acting on | Check the id and the org_id |
conflict | 409 | Domain already protected, duplicate policy name, alert reopened as a duplicate, or Idempotency-Key reused on a different route | Read message; adjust the data or generate another key |
rate_limited | 429 | More than 120 requests per minute in the organization | Wait for the Retry-After time and try again |
internal_error | 500 | Unexpected error at Proteside | Retry with backoff; if it persists, contact support |
A resource from another organization responds 404, not 403: the API doesn't reveal whether the id exists
elsewhere.
Rate limit
The limit is 120 requests per minute per organization that owns the token, over a 60-second sliding window. It's shared by all of the organization's tokens and, for partners, also by calls made in child organizations.
Every authenticated response includes:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Per-minute limit (120) |
X-RateLimit-Remaining | Requests remaining in the window (approximate) |
X-RateLimit-Reset | The time, in Unix seconds, when you can count on the window being renewed |
Once you exceed the limit, the response is 429 rate_limited with Retry-After: 60. Calls with an invalid token
(401) don't count; calls with a valid token but missing scope (403) do.
Treat every 429 the same way
During internal instability, the API prefers to reject a request with 429 rather than let uncontrolled traffic
through. That's why you may get a 429 even below the limit. Don't try to guess the cause: wait for Retry-After
and retry.
Retry 429 and 500 errors and network failures with increasing backoff. Other 4xx errors don't improve with a
retry.
async function protesideFetch(url, init = {}, attempt = 0) {
const res = await fetch(url, init);
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= 4) return res;
const retryAfter = Number(res.headers.get('Retry-After'));
const waitMs = retryAfter > 0
? retryAfter * 1000
: Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 500;
await new Promise((r) => setTimeout(r, waitMs));
return protesideFetch(url, init, attempt + 1);
}Idempotency
Send the Idempotency-Key header (1 to 255 characters) on any POST so you can safely retry it, for example after a
timeout. If the same key arrives again within 24 hours, the API doesn't run the operation again: it returns the
same status and body as the first response, with the Idempotency-Replayed: true header.
curl -s -X POST "https://app.proteside.com/api/v1/stores" \
-H "Authorization: Bearer $PROTESIDE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3b8f2c1e-6d4a-4f0b-9c2e-7a1d5e8f9b30" \
-d '{"domain":"mystore.com"}'- The header is ignored on
GET,PATCHandDELETE. - Using the same key with a different method or path returns
409 conflict. - Error responses are stored too: if the first attempt got a
400, retrying with the same key returns the same400for 24 hours. Fixed the request? Generate a new key. - PDF and CSV downloads aren't stored.
Use a new key (UUID) for each operation
The key applies to the entire organization that owns the token, and the comparison only considers the method and path, without the query string or body. In practice:
- Reusing the key with a different body returns the first call's response, without warning.
- Partners:
POST /stores?org_id=AandPOST /stores?org_id=Bwith the same key are treated as the same operation, and the second call gets the store created in child A. - Two simultaneous requests with the same key may both be executed. Retry only after the first one finishes or times out.
Auditing
Every change made through the API is recorded in the audit log with the author api:<token prefix>, for example
api:ps_live_Ab3dEf6hIj9kLm2nOp5qRs8t. The same label appears in the data: in authorized_by on an authorized
script and in resolved_by_label on a resolved alert. That's why it pays to create one token per integration: the
audit log shows which system made each change.