# Projekt REST API — full developer docs > The Projekt REST API gives Claude, Codex, and your own scripts programmatic > access to projects, issues, sprints, docs, time tracking, invoicing, and CRM > data — gated by a single organization-scoped API key. The same endpoints > power the web app, the iOS app, and the Android app, so everything you see > in the UI you can drive over HTTPS. > > Base URL: https://projekt.3xa.es/api > OpenAPI: https://projekt.3xa.es/openapi.yaml > Wiki: https://projekt.3xa.es/developers/ ============================================================================= # Design principles ============================================================================= - One org per key. An API key is bound to a single organization at mint time. The token cannot be used to list, join, or discover other orgs the human happens to belong to. - Same surface as the apps. No "API-only" or "web-only" routes. Everything the iOS app, the Android app, or the web SPA can do is exposed through the same JSON endpoints. - Bearer + JSON. Authenticate with `Authorization: Bearer pjk_live_...`. Bodies are application/json. Timestamps are ISO 8601. IDs are UUIDs. - Predictable shape. Successes return the resource (or `{ data, meta }` for paginated lists). Errors return `{ "error": "" }` with the right HTTP status. - Role-aware. The key inherits the human's role in the org. Owners and admins can do everything; members and viewers see only what the UI shows them. ============================================================================= # Quickstart ============================================================================= ## Prerequisites - A Projekt account (free signup at https://projekt.3xa.es/). - At least one organization where your role is owner, admin, or manager (these can mint keys). - A terminal with curl, or any HTTP client. ## 1. Mint a key Sign in to Projekt, open Organization -> Settings -> General -> Integraciones, click "Create API key", give it a descriptive name (e.g. "Claude desktop", "CI deploy", "nightly-backup"), optionally set an expiry. You will see the plaintext token EXACTLY ONCE. Copy it now into a secret manager — Projekt only stores its SHA-256 hash, so a lost key cannot be recovered, only revoked and replaced. Token format: `pjk_live_<32 base62 chars>` (~190 bits of entropy). The prefix lets secret scanners spot leaks; the rest is the actual secret. ## 2. Call /me curl https://projekt.3xa.es/api/me \ -H "Authorization: Bearer pjk_live_..." Returns: { "id": "ad8c...", "email": "you@example.com", "name": "You", "username": "you", "locale": "es" } ## 3. List your orgs curl https://projekt.3xa.es/api/me/orgs \ -H "Authorization: Bearer pjk_live_..." Even though a PAT is bound to a single org, /me/orgs returns the one org plus its projects. Use the `org_id` you find here for `X-Org-Id` on subsequent calls. ## 4. List issues in a project curl "https://projekt.3xa.es/api/issues?project_id=" \ -H "Authorization: Bearer pjk_live_..." \ -H "X-Org-Id: " ## 5. Create an issue curl -X POST https://projekt.3xa.es/api/issues \ -H "Authorization: Bearer pjk_live_..." \ -H "X-Org-Id: " \ -H "Content-Type: application/json" \ -d '{ "project_id": "", "title": "Investigate flaky CI test", "status": "Backlog", "priority": "medium", "description": "The auth e2e test fails 1 in 5 runs." }' ## Examples in other languages Python: import os, requests TOKEN = os.environ["PROJEKT_API_KEY"] ORG_ID = os.environ["PROJEKT_ORG_ID"] r = requests.get( "https://projekt.3xa.es/api/issues", params={"project_id": "..."}, headers={ "Authorization": f"Bearer {TOKEN}", "X-Org-Id": ORG_ID, }, timeout=15, ) r.raise_for_status() for issue in r.json(): print(issue["title"]) TypeScript / Node: const res = await fetch("https://projekt.3xa.es/api/issues?project_id=...", { headers: { Authorization: `Bearer ${process.env.PROJEKT_API_KEY}`, "X-Org-Id": process.env.PROJEKT_ORG_ID!, }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const issues = await res.json(); PHP: $ch = curl_init("https://projekt.3xa.es/api/issues?project_id=..."); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("PROJEKT_API_KEY"), "X-Org-Id: " . getenv("PROJEKT_ORG_ID"), ], CURLOPT_RETURNTRANSFER => true, ]); $issues = json_decode(curl_exec($ch), true); ============================================================================= # Authentication ============================================================================= Three credential types on the same API surface: | Mode | Best for | Lifetime | Identity | |-----------------------|------------------------------------------------------------------------------------------|--------------------------------|-------------------------------------------| | Personal Access Token | Claude, Codex, CI scripts, cron jobs, your own backend talking to Projekt. | Until revoked (opt. expiry). | Acts as the human. Bound to ONE org. | | OAuth 2.1 + DCR | Third-party SaaS where the user signs in with Projekt and picks org/scopes to grant. | Access 1h, refresh long-lived. | Acts as the user. RFC 8707 resource bind. | | Browser session | The Projekt web app itself. | 30 days, refresh-on-use. | The signed-in user. | If in doubt, use a PAT. ## Personal Access Tokens Headers: Authorization: Bearer pjk_live_... X-Org-Id: (optional for PAT — already bound) Fallback if your proxy strips `Authorization`: `X-Auth-Token: pjk_live_...`. If you do send `X-Org-Id`, it MUST match the key's bound org. Mismatches return 403 to prevent scope-creep. Constraints: - A PAT is bound to exactly one organization at mint time. - It acts as the human who created it, with their org role. - Endpoints that operate across organizations return 403 for PATs: POST /orgs, POST /orgs/join, GET /orgs/search, GET /crossorg/orgs/search. - /orgs, /me/orgs, /people/search, /palette/search are filtered to the key's bound org — never leak data from other orgs the human belongs to. - The key cannot mint or revoke other API keys. - Limit: up to 20 active keys per user per org. Revoke at Organization -> Settings -> General -> Integraciones. Revocation is instant — the next request returns 401. ## OAuth 2.1 Discovery: GET https://projekt.3xa.es/.well-known/oauth-authorization-server Dynamic Client Registration (RFC 7591): POST https://projekt.3xa.es/oauth/register Content-Type: application/json { "client_name": "My integration", "redirect_uris": ["https://example.com/cb"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none" } Then standard authorization-code + PKCE. The `resource` parameter (`https://projekt.3xa.es/api`) is required; the org is selected on the consent screen and baked into the access token (RFC 8707). OAuth is intended for third-party SaaS where a user delegates access. For scripts you control, use a PAT. ## Security tips - Never commit a key. Use a secret manager. - One key per integration. Name them after the tool. - Rotate on incident. - A previously-working request returning 401 means the key was revoked or expired — re-authenticate, do not retry blindly. ============================================================================= # Errors ============================================================================= ## Envelope Every non-2xx response: { "error": "Human-readable description." } Validation errors add a structured `errors` map: { "error": "Validation failed", "errors": { "title": ["The title field is required"], "priority": ["The priority must be one of: low, medium, high, urgent"] } } ## Status codes | Code | Meaning | Action | |------|--------------------------------------------------------------------------------------------------|---------------------------------------------------| | 200 | Success. Body is the resource (or `{data, meta}` for paginated lists). | Carry on. | | 201 | Resource created. | Body has the new resource, including its `id`. | | 204 | Success, no body. Common on DELETE. | Treat as OK. | | 304 | Not Modified (ETag). | Use cached copy. | | 400 | Bad request. Malformed JSON, missing required header. | Fix the request. Do not retry. | | 401 | Unauthorized. Missing, expired, or revoked token. | Re-authenticate. Do not retry blindly. | | 403 | Forbidden. Wrong role, PAT on cross-org endpoint, X-Org-Id mismatch. | Check role + org binding. Read the message. | | 404 | Not found. | Verify the ID and `X-Org-Id`. | | 405 | Method not allowed. | Check the OpenAPI for allowed methods. | | 409 | Conflict. Duplicate slug/key, concurrent edit. | Re-read and reconcile. | | 422 | Validation failed. | Inspect `errors`, fix fields. | | 429 | Too Many Requests. | Honor `X-RateLimit-Reset`. | | 500 | Internal server error. | Retry with backoff. If persistent, report. | | 502/503 | Upstream/gateway error. Transient. | Retry with exponential backoff (cap ~30s). | ## Retry policy - Idempotent (GET, HEAD, PUT, DELETE): safe to retry on 5xx and 429 with exponential backoff. - Non-idempotent (POST, PATCH): retry only on 429 and transport-level failures. Never retry a 500 on POST blindly — the resource may exist. - 4xx (except 429): never retry. - Backoff: start at 500ms, double each attempt, cap at 30s, give up after 6 tries. ============================================================================= # Rate limits ============================================================================= ## Global ceiling 600 requests per 60-second sliding window, per token. Unauthenticated calls are bucketed by IP at the same rate. In practice: sustained 10 req/s with headroom for bursts. ## Per-route limits On top of the global ceiling, certain endpoints carry tighter caps: | Endpoint family | Limit | |----------------------------------------------------------------------------------|-------------| | POST /api-keys | 10 / hour | | GET /orgs/search, /people/search, /palette/search | 30 / 60s | | POST /projects, PUT /projects/:id, DELETE /projects/:id | 30 / 60s | | POST /oauth/register | 5 / hour | | POST /oauth/token | 60 / 60s | ## Response headers (on every response) X-RateLimit-Limit — bucket size X-RateLimit-Remaining — calls left in this window X-RateLimit-Reset — seconds until the bucket refills On a 429, treat `X-RateLimit-Reset` like `Retry-After`. ## Recommended client behaviour 1. Read `X-RateLimit-Remaining` from every response. If it drops below ~10%, slow down voluntarily. 2. On 429, sleep `X-RateLimit-Reset` seconds and retry once. 3. On two consecutive 429s, exponential backoff (cap 30s, 6 tries). 4. For bulk jobs, mint one PAT per worker so each has its own bucket. Python skeleton: import time, requests def call(method, url, **kw): for attempt in range(6): r = requests.request(method, url, **kw) if r.status_code != 429: return r wait = int(r.headers.get("X-RateLimit-Reset", "5")) time.sleep(min(wait, 30)) r.raise_for_status() return r ============================================================================= # Conventions ============================================================================= - Base URL: https://projekt.3xa.es/api - Auth: `Authorization: Bearer pjk_live_...` (Bearer scheme). - Org context: `X-Org-Id: ` on org-scoped endpoints. - Content-Type: `application/json` on every body. - IDs: UUID v4 (CHAR(36)). - Timestamps: ISO 8601 (`2026-05-28T09:14:02Z`). - Pagination: `?limit` + `?offset` on most list endpoints; some use cursor. Paginated list responses follow `{ data: [...], meta: { total, page, per_page, pages } }`. - ETags: GET responses carry an ETag. Send `If-None-Match` to get 304. ============================================================================= # Where to go next ============================================================================= - Live API reference (Scalar): https://projekt.3xa.es/developers/reference.html - Canonical OpenAPI YAML: https://projekt.3xa.es/openapi.yaml - Manage your API keys: https://projekt.3xa.es/app/#/organization?tab=settings&sub=general§ion=integraciones