# Trakk Agency API - Reference

Version: v1
Base URL: `https://admin.trakk.ai/api/v1`
Auth: `Authorization: Bearer {token}` on every request.
All responses: JSON.

---

## Authentication & limits

Tokens are scoped to a single agency. Super-admin tokens use `abilities: ["*"]` and can access all agencies.

**Rate limits** (enforced simultaneously; limits are per-token and configurable):
- Default: 60 req/min, 1 000 req/hour
- Headers on every response: `X-RateLimit-Limit`, `X-RateLimit-Remaining`
- On 429: `Retry-After`, `X-RateLimit-Reset` (may reflect minute or hour window)

**IP restrictions:** tokens may be restricted to specific IPs or CIDR ranges. Unlisted IPs get 403.

---

## Response shapes

| Endpoint type | Shape |
|---|---|
| `GET /me` | bare object |
| Single resource | `{ "data": { ... } }` |
| Collection | `{ "data": [ ... ] }` |
| Paginated collection | `{ "data": [...], "meta": {...}, "links": {...} }` |

Cached endpoints include `"cache_refreshed_at": "<ISO 8601>"` at the top level alongside `data`. Cache TTL: 1 hour. Cache is invalidated automatically on writes.

Pagination: only `GET /projects` is paginated (100 per page). Use `?page=N`.

---

## Error envelope

```json
{ "error": { "code": "<code>", "message": "<human-readable>" } }
```

| HTTP | code | Meaning |
|---|---|---|
| 400 | `bad_request` | Unknown param, out-of-range value, invalid input |
| 401 | `unauthenticated` | Missing or invalid token |
| 403 | `forbidden` | Wrong agency, IP not whitelisted, insufficient scope |
| 404 | `not_found` | Resource not found |
| 405 | `method_not_allowed` | Wrong HTTP method |
| 422 | `validation_error` | Input validation failed |
| 429 | `rate_limit_exceeded` | Per-minute or per-hour limit exceeded |
| 500 | `server_error` | Unexpected error; no internal details exposed |

---

## Enums

**Project `status`:** `active`, `pending`, `pending - in progress`, `paused`, `ended`, `termination pending`

**Report `status`** (on `/reports`, `/reports/latest`) **and `generation_status`** (same values, on `/content`):
`pending`, `running`, `completed`, `failed`

**Report `approved_status`:** `waiting`, `on-hold`, `approved`, `blocked`, `null`

**Report `managed_status`:** `in-progress`, `on-hold`, `completed`, `waiting-on-client`, `null`

**Token `status`:** `active`, `disabled`

---

## Endpoints

### GET /me
No params. Returns bare object (no `data` wrapper).
```
{ id, name, agency_id, status, abilities[] }
```
`agency_id` is null for super-admin tokens. `abilities: ["*"]` = full access.

---

### GET /projects
Cached. Paginated (100/page).

Query params: `status`, `search` (name/domain partial match), `agency_id` (super-admin only), `page`, `per_page` (max 100). Unknown params → 400.

Each item:
```
{ id, name, status, keyword_count, latest_report_generated_at, latest_report_id,
  latest_report_number, latest_report_status, links.latest_report }
```
`latest_report_*` fields are null if the project has no reports. `latest_report_number` is the `report_number` (1-26), not the internal ID.

---

### GET /projects/{project}
Cached. All fields from the list plus:
```
company_name, domain, created_at,
country: { id, label },
text_language: { id, label },
report_language: { id, label },
managing_user: <UserObject>,
created_by: <UserObject>,
contact_email, client_emails[], cc_email, receives_monthly_reports,
agency,   ← plain string (agency name), NOT an object
plan, gsc_status
```

**Note:** `project.agency` is a plain string (name only). User objects embed `agency: { id, name }` - different shape.

**UserObject:** `{ id, name, role, agency: { id, name } | null }`

---

### GET /projects/{project}/reports
Cached. Returns all reports newest first.

Each report:
```
{ id, project_id, report_number, report_name, status,
  date_to_create_texts, date_to_send_to_client, sent,
  approved_status, approved_by: <UserObject>|null,
  managed_status, managed_by: <UserObject>|null,
  pdf_link, web_link }
```
`report_number` is the position in the delivery sequence (1-26). `status` uses the report status enum.

---

### GET /projects/{project}/reports/latest
Cached. Returns `{ data: <ReportObject>, cache_refreshed_at }` - same shape as one item from the list. Returns 404 if no reports exist.

**Key field to check before fetching content:** `data.status === "completed"`

---

### GET /projects/{project}/reports/{report_number}/content
Cached. `report_number` is 1-26. Returns 400 if out of range, 404 if that report doesn't exist.

Top-level fields:
```
report_number, report_id, report_name,
content_scheme,        ← string identifier; determines keyword content shape
content_scheme_fields, ← field names present in each content block
links: { project_website, report_web, report_pdf },
project_id, project_domain, project_status,
is_generated,          ← true when generation_status === "completed"
generation_status,     ← same values as report `status`
approved_status, approved_by, managed_status, managed_by,
date_to_create_texts, date_to_send_to_client,
summary: { keyword_count, keywords_with_content },
keywords: [ <KeywordContentObject> ]
```

Each keyword content object:
```
{ keyword_id, keyword, keyword_url, has_content,
  content: <object|array|null>,
  html:    <object|array|null> }
```
`content` and `html` are null when `has_content` is false. Shape depends on `content_scheme` - see Content Schemes below.

---

### GET /projects/{project}/keywords
Cached. Returns all active keywords for the project.

Each keyword:
```
{ id, keyword, url, create_date, latest_position, status_200,
  content_count,      ← number of reports with content for this keyword
  content_action_ids  ← sorted array of report_numbers (1-26) that have content }
```

---

### GET /countries, GET /countries/{country}
Not cached. Each item: `{ id, label }`. Use IDs in project metadata.

### GET /languages, GET /languages/{language}
Not cached. Each item: `{ id, label }`.

### GET /users, GET /users/{user}
Not cached. Returns users visible to the token (same agency + internal Trakk users). Each user uses the same compact UserObject shape as embedded in project/report responses: `{ id, name, role, agency: { id, name } | null }`.

---

## Content Schemes

Always read `content_scheme` before accessing keyword content. Do not hardcode field names.

**Array schemes** - `content` and `html` are arrays of variant objects (pick one to publish):

| Scheme | Variants | content fields | html keys |
|---|---|---|---|
| `metadata` | 3 | `meta_title`, `meta_description` | `meta_title`, `meta_description` |
| `h1_headlines` | 3 | `h1` | `h1` |
| `h2_text_blocks` | 3 | `h2`, `text` | `h2_and_text` |
| `h3_text_blocks` | 3 | `h3`, `text` | `h3_and_text` |
| `h4_text_blocks` | 6 | `h4`, `text` | `h4_and_text` |
| `qa_block` | 4 pairs → content array, then single html object | `question`, `answer` | html: `{ qa: "<rendered>" }` |
| `link_blocks` | 2 | `h2`, `text`, `links: [{anchor_text, url}]` | `h2_and_text` (links not in html) |

**Object schemes** - `content` and `html` are single objects:

| Scheme | content fields | html keys |
|---|---|---|
| `single_text_block` | `text` | `text` |
| `h2_summary_block` | `h2`, `texts: [str, str]` | `texts: [rendered, rendered]` |
| `subnavigation` | `links: [{keyword, url}]` | `subnavigation: "<div>..."` |
| `bullet_points` | `h2`, `text`, `bullet_points: [str]` | `bullet_points: "<h2>...<ul>..."` |
| `numbered_points` | `h2`, `text`, `numbered_points: [str]` | `numbered_points: "<h2>...<ol>..."` |
| `image_alt_text` | `search_terms: [str, str]`, `alt_texts: [str×4]` | `alt_texts: [<img>×4]` |

**`qa_block` shape note:** `content` is an array of 4 `{question, answer}` objects. `html` is a single object `{ qa: "<rendered block>" }`.

**`link_blocks` note:** `links` appear only in `content`. The `html` field contains heading+text only - links must be wired up manually.

**Safe pattern for any scheme:**
```js
const variant = Array.isArray(keyword.content) ? keyword.content[0] : keyword.content;
```

---

## Report number mapping (action_number → content_scheme)

| report_number | name | scheme |
|---|---|---|
| 1 | SEO Text | `single_text_block` |
| 2, 19 | Meta data | `metadata` |
| 3 | H1 Headlines | `h1_headlines` |
| 4, 13, 16 | H2 + Text | `h2_text_blocks` |
| 5, 7, 14, 17, 26 | H3 + Text | `h3_text_blocks` |
| 6, 10, 23, 25 | Q&A | `qa_block` |
| 8 | Images + alt-texts | `image_alt_text` |
| 9 | Text + internal links | `link_blocks` |
| 11, 24 | H2 + Summary | `h2_summary_block` |
| 12 | Subnavigation | `subnavigation` |
| 15, 18, 21 | H4 + Text | `h4_text_blocks` |
| 20 | Bullet Points | `bullet_points` |
| 22 | Numbered Points | `numbered_points` |

---

## Versioning

Non-breaking changes (new fields, new enum values, new optional params, new endpoints) may be introduced at any time without a version bump. Write integrations defensively - ignore unknown fields. Breaking changes are released under a new versioned path (e.g. `/api/v2`) with advance notice.
