Proteside Docs

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 (POST and PATCH) need the Content-Type: application/json header, and the body must be a JSON object. Otherwise, the response is 400 invalid_request.
  • Unknown fields are rejected. A field the endpoint doesn't accept returns 400 invalid_request with the message Unexpected 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. An id in the wrong format returns 400.
  • Successful responses include Cache-Control: no-store. Creates respond 201; all other operations respond 200.

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.

list-all.js
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.

errorHTTPWhen it happensWhat to do
invalid_request400Malformed body, unknown field, out-of-range value, invalid cursor or limitFix the request; don't retry it unchanged
unauthorized401Token missing, malformed, revoked or expiredCheck the token; create another one if it was revoked or expired
insufficient_scope403The token doesn't have the endpoint's scope, or tried to act on a child without orgs:manageCreate a token with the scope listed in the scopes table
plan_limit403The plan's store limit was reached, or the PCI DSS report isn't included in the planUpgrade the plan or suspend a store you no longer use (the API doesn't reactivate suspended stores)
not_found404The resource doesn't exist or doesn't belong to the organization you're acting onCheck the id and the org_id
conflict409Domain already protected, duplicate policy name, alert reopened as a duplicate, or Idempotency-Key reused on a different routeRead message; adjust the data or generate another key
rate_limited429More than 120 requests per minute in the organizationWait for the Retry-After time and try again
internal_error500Unexpected error at ProtesideRetry 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:

HeaderMeaning
X-RateLimit-LimitPer-minute limit (120)
X-RateLimit-RemainingRequests remaining in the window (approximate)
X-RateLimit-ResetThe 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.

retry.js
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, PATCH and DELETE.
  • 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 same 400 for 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=A and POST /stores?org_id=B with 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.

Next steps

On this page