This guide gets you from "I have credentials" to "I'm making authenticated calls and getting data back" in about ten minutes. It covers the concepts you'll need long-term — auth, scopes, pagination, idempotency, errors — but doesn't list every endpoint. For the full endpoint catalog with request/response schemas and an interactive try-it console, see external-api.pirros.com/docs.
If you don't yet have credentials, your firm admin issues them — point them at Set up API access for your firm.
What you'll need
A Client ID and Client Secret from your firm admin.
The workspace UUID(s) your credentials have access to.
Any HTTP client. The examples here use curl.
Quickstart
Two requests, in order.
1. Get an access token.
curl -X POST https://{{TOKEN_HOST}}/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET" }'
Response:
{ "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600, "request_id": "8e1a3c4f-..."}
2. Use the token to list tags in a workspace.
curl https://external-api.pirros.com/v1/workspaces/YOUR_WORKSPACE_UUID/tags \ -H "Authorization: Bearer eyJhbGciOi..."
Response:
{ "response": { "data": [ { "id": "...", "name": "concrete", "workspace_id": "...", "created_at": "2026-06-04T12:00:00.000Z" }, { "id": "...", "name": "steel", "workspace_id": "...", "created_at": "2026-06-04T12:00:00.000Z" } ], "next_cursor": null, "expanded": {} }, "meta": { "request_id": "8e1a3c4f-..." }}
If that worked, you're set. The rest of this guide explains what's actually happening and the patterns you'll see across other endpoints.
Every /v1/* success response uses this envelope: a top-level response object holding the payload (for lists, data + next_cursor + expanded), and a sibling meta object holding request_id. Parse body.response, not body.data.
Authentication
Pirros uses OAuth 2.0 client-credentials flow. There are no user logins, no browser redirects, and no refresh tokens — just a server-to-server exchange of client ID + secret for a short-lived JWT.
Token lifetime: 1 hour. Cache the token until close to expiry, then request a new one. Pirros doesn't issue refresh tokens — you re-run the POST /oauth/token call.
Revoking access. If a credential is compromised, an admin revokes it from Settings → API Keys (Revoke, or Regenerate the secret). Revocation is immediate — the next API call with a token from that key fails 401 invalid_token. (There is no developer-side token-revocation call you need to make; access is controlled at the key level by the admin.)
Note on hosts. The OAuth token endpoint is served from {{TOKEN_HOST}}; all /v1/* content endpoints are served from external-api.pirros.com. (Confirm exact production hosts before final publication.)
Request shape
All content endpoints are workspace-prefixed:
GET /v1/workspaces/{workspaceId}/...
{workspaceId} is a workspace UUID and must match a workspace your credentials were granted access to.
How an inaccessible workspace/resource responds depends on the endpoint type:
Single-resource GET / download (/families/{id}, /details/{id}, /stashes/{id}, downloads): a missing resource, a resource in another workspace/firm, or one your grants don't cover all collapse to 404 not_found — Pirros doesn't distinguish "doesn't exist" from "you can't see it," so you can't probe for other firms' data.
List / search (/tags, /details search): if your credentials hold no eligible grant for that workspace + resource type, you get 403 insufficient_scope; a workspace you simply have no data in returns 200 with an empty data array.
Required header on every authenticated request:
Authorization: Bearer <access_token>
Pirros echoes a request ID back on every response:
X-Request-Id: 8e1a3c4f-...
Include this ID in support tickets — it lets the Pirros team find your request in our logs. The same value appears in the body as meta.request_id.
Scopes and resource filters
Your credentials carry a list of scope grants. Each grant is the combination of a workspace, a filter, and an action (read / write / download). On each request, Pirros checks whether your credentials hold a grant matching the workspace in the URL, the resource type being accessed (families, details, stashes, tags), and the required action.
Your firm admin sets these up — see Resource filters: controlling what your API integrations can access for the concept.
What you'll see as a developer:
403 insufficient_scope — your credentials don't have a matching grant (returned on list/search and write endpoints; single-resource GETs return 404 instead). The detail fields live under error.details, including the filters your credentials do hold for this workspace, which is usually enough to figure out what's missing:
{ "error": { "code": "insufficient_scope", "message": "Token has no eligible filter for this resource", "details": { "required_action": "read", "entity_type": "detail", "entity_id": "d4f...", "granted_filter_ids": ["..."], "granted_filter_names": ["Concrete details (typical project)"] } }, "meta": { "request_id": "8e1a3c4f-..." }}
If you see this, ask the firm admin to broaden the grant — they can either add a wider system filter (e.g. default:details) or author a custom filter that matches what you're trying to access.
Pagination
List endpoints use cursor-based pagination. Pass cursor and limit as query params:
curl "https://external-api.pirros.com/v1/workspaces/{ws}/details?limit=100" \ -H "Authorization: Bearer ..."
Response (note the standard response / meta envelope — next_cursor is inside response, not at the top level):
{ "response": { "data": [ /* up to 100 items */ ], "next_cursor": "eyJjcmVhdGVkX2F0...", "expanded": {} }, "meta": { "request_id": "..." }}
To get the next page, pass response.next_cursor back as the cursor query param. When response.next_cursor is null, you've reached the end.
Default limit: 25. Max limit: 100. (A limit outside [1, 100] is rejected with 400 — it is not clamped.)
Exception: GET /v1/workspaces/{ws}/tags returns up to 500 tags in a single response with next_cursor: null — tag counts are capped, so paging isn't needed.
Idempotency
Write endpoints (POST /stashes, PATCH /stashes/{id}) accept an Idempotency-Key header. Include a unique value (a UUID is fine) on every write:
curl -X POST https://external-api.pirros.com/v1/workspaces/{ws}/stashes \ -H "Authorization: Bearer ..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: a3f1e8c2-..." \ -d '{ "name": "My stash", "description": "optional", "project_id": "PROJECT_UUID", "is_permanent": false, "item_ids": { "detail_ids": ["DETAIL_UUID"], "family_ids": [] } }'
The create body is strict: name, project_id (UUID), and item_ids (detail_ids + family_ids arrays) are required; description and is_permanent are optional. Unknown fields are rejected with 400.
If your client retries (network blip, ambiguous timeout), Pirros recognizes the repeated key and returns the original response instead of creating a duplicate. Keys are remembered for 24 hours. Reusing a key with a different request body returns 409 IDEMPOTENCY_CONFLICT.
If you don't supply the header, every write is treated as new — a retry after an ambiguous timeout will create a duplicate stash. Always send the header on writes.
File downloads (RFA / PDF / RVT)
Download endpoints don't stream files directly. Instead, they return the standard {response, meta} envelope whose payload carries a download_url:
curl https://external-api.pirros.com/v1/workspaces/{ws}/downloads/pdf/{detailId} \ -H "Authorization: Bearer ..."
Response:
{ "response": { "download_url": "https://res.cloudinary.com/...", "expires_at": null, "expanded": {} }, "meta": { "request_id": "..." }}
Fetch the file from response.download_url.
URL lifetime differs by file type:
RVT (detail) and RFA (family) are short-lived signed S3 URLs — expires_at is a timestamp ~15 minutes out; fetch promptly.
PDF (detail) is a public Cloudinary delivery URL with a download disposition — it does not expire, so expires_at is null.
Don't auto-follow redirects. The JSON wrapper is intentional — most HTTP clients can't authenticate against the signed S3 URL the same way they authenticate against Pirros, so we hand you the URL explicitly rather than redirecting.
The three download endpoints:
GET /v1/workspaces/{ws}/downloads/pdf/{detailId} — detail PDF (Cloudinary, non-expiring)
GET /v1/workspaces/{ws}/downloads/rvt/{detailId} — detail RVT source (signed S3, ~15 min)
GET /v1/workspaces/{ws}/families/{id}/rfa — family RFA file (signed S3, ~15 min)
Errors
Most /v1/* responses use this envelope on failure:
{ "error": { "code": "machine_readable_code", "message": "Human-readable description" }, "meta": { "request_id": "8e1a3c4f-..." }}
/oauth/* endpoints follow RFC 6749's flat shape instead:
{ "error": "invalid_client", "error_description": "...", "request_id": "8e1a3c4f-..."}
Common codes:
HTTP | Code | What it means |
400 | invalid_request | Body / query params don't match the schema. Check /docs. (Related: invalid_include_param, INVALID_ITEM_REFERENCE.) |
401 | invalid_token | Missing, malformed, expired, or revoked bearer token. Get a new one. |
403 | insufficient_scope | Token is valid but lacks the required scope/filter grant. Ask the firm admin. (Related 403 codes: invalid_filter, access_denied, DOWNLOAD_DISABLED.) |
404 | not_found | Resource doesn't exist or exists but isn't in your workspace/grant (single-resource GET & downloads). |
409 | IDEMPOTENCY_CONFLICT | Idempotency-Key reused with a different request body. |
429 | too_many_requests | You're over the rate limit. Honor Retry-After. See the note below — the 429 body is shaped differently. |
503 | service_unavailable | The API is temporarily disabled (operator kill switch). Retry later. |
5xx | internal_error | Pirros side. Retry with exponential backoff; if it persists, contact support with the request_id. |
429 is the one exception to the /v1 error envelope. The rate limiter returns a flat body, not the { error: { code, message }, meta } shape: json { "error": "too_many_requests", "error_description": "Rate limit exceeded. Please retry after the indicated time.", "retry_after": 1234, "request_id": "8e1a3c4f-..." }
Always include request_id when you contact support — it's how we find your specific request in our logs.
Rate limits
Token endpoint (POST /oauth/token): 5 attempts per 15-minute window, keyed by client ID and source IP. (A successful token response resets the counter.)
API endpoints (/v1/*): 1,000 requests per hour per credential.
When you hit a limit, Pirros returns 429 with a Retry-After header (in seconds). Wait that long before retrying.
Best practices:
Cache tokens. They last an hour; don't request a new one per call.
Honor Retry-After. Don't tight-loop.
Use exponential backoff on 5xx and 429.
Reference
For the full endpoint catalog — every route, every request schema, every response schema, plus an interactive try-it console — see external-api.pirros.com/docs.
What's covered in v1:
Tags: list
Details: get one, search, download PDF, download RVT
Families: get one, get similar families, download RFA
Stashes: get one, create, update
Endpoints not yet in v1 but planned: family upload, family history, detail flags, family references, family group.
Support
If you hit a snag, contact [email protected]. Include:
The request_id from the response that failed
Approximate timestamp
What you were trying to do
Your client ID (never the secret)
We can trace requests by request_id and respond faster when you include it.
