> ## Documentation Index
> Fetch the complete documentation index at: https://docs.raisegate.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions

> IDs, pagination, compact views, fuzzy lookup, sector filters, idempotency, and rate limits.

Base URL: `https://app.raisegate.com`. Version: `v1`. Every path starts with `/api/v1`. JSON bodies, UTF-8. Live spec: `GET /api/v1/openapi` (no authentication).

## IDs and timestamps

* All resource IDs are UUIDs. Malformed IDs return `400 validation_error`. Unknown IDs, and IDs belonging to another organisation, return `404 not_found`.
* Timestamps are ISO 8601 in UTC, as stored (for example `2026-09-17T21:24:36.654275+00:00`).
* Date filters (`since`, `until`, `updatedAfter`) accept a date (`2026-09-15`) or a full timestamp. For `until`, a bare date includes that whole UTC day; a timestamp is an exclusive upper bound. `since` must be earlier than `until`.

## Pagination

Every list endpoint is newest-first and cursor-paginated.

* `limit`: 1-100, default 25. Values above 100 are clamped to 100; non-numeric values return `400`.
* The response carries `nextCursor`; pass it back as `cursor` for the next page. `nextCursor: null` means there are no more results.
* Cursors are opaque and strictly validated. Tampered or malformed cursors return `400 invalid_cursor`.
* Ordering is by creation time with the row ID as a tie-breaker, so paging never skips or repeats rows, even when rows share a timestamp.

```json theme={"theme":{"light":"github-light-default","dark":"vesper"}}
{ "data": [ "..."], "nextCursor": "eyJjcmVhdGVkQXQiOi..." }
```

## Response views

List endpoints return a **compact** view by default; single-resource `GET`s return the **full** view. Either can be requested explicitly:

```http theme={"theme":{"light":"github-light-default","dark":"vesper"}}
GET /api/v1/tracker?view=full
GET /api/v1/tracker/{id}?view=compact
```

Compact views keep what a person or agent needs to scan and decide: summaries are cut to 300 characters and at most three evidence links are included. Filters, ordering, IDs and cursors are identical in both views. Any other `view` value returns `400 validation_error`.

Sizes for 25 items, measured on a live organisation with 80 tracked companies (they grow with the amount of tracked history):

| List       | `view=full`              | compact (default)     |
| ---------- | ------------------------ | --------------------- |
| `/tracker` | \~410 KB (\~105k tokens) | \~34 KB (\~9k tokens) |
| `/leads`   | \~170 KB (\~44k tokens)  | \~26 KB (\~7k tokens) |
| `/signals` | \~85 KB (\~22k tokens)   | \~24 KB (\~6k tokens) |
| `/alerts`  | \~54 KB (\~14k tokens)   | \~19 KB (\~5k tokens) |

<Tip>
  Keep list tools on the compact view. Fetch full detail only for the item the user is looking at.
</Tip>

## Fuzzy entity lookup

Endpoints that filter by tracked company accept either an exact ID or a natural name:

| Endpoint              | Exact            | Fuzzy         |
| --------------------- | ---------------- | ------------- |
| `/signals`, `/alerts` | `entityId`       | `entityQuery` |
| `/leads`              | `sourceEntityId` | `sourceQuery` |

Fuzzy lookup matches company and founder names and tolerates case, spacing, punctuation, joined words, legal suffixes and small typos. These variants all resolve to the same company, whether it is stored as "Anomaly", "AnomalyBio", "Anomaly Bio" or "Anomaly Biosciences, Inc.":

```text theme={"theme":{"light":"github-light-default","dark":"vesper"}}
Anomaly · AnomalyBio · Anomaly Bio · anomaly-bio · anomalybio.com
Anomaly Biosciences · Anomaly Inc · anomly
```

Founder names work too (`sourceQuery=Suraj Prasd` finds Suraj Prasad's company). The response reports what matched:

```json theme={"theme":{"light":"github-light-default","dark":"vesper"}}
"sourceResolution": {
 "query": "anomaly bio",
 "matchedOn": "companyName",
 "score": 1,
 "entity": {
  "id": "205ad44e-…",
  "companyName": "Anomaly Bio",
  "founderName": "Armaan Dhanda, Samyak Baid"
 }
}
```

(`entityResolution` on `/signals` and `/alerts`.)

| Result                                 | Code                                                                                   |
| -------------------------------------- | -------------------------------------------------------------------------------------- |
| Nothing matches                        | `404 entity_not_found` / `source_not_found`                                            |
| Several companies about equally likely | `409 ambiguous_entity` / `ambiguous_source` with up to five `error.details.candidates` |
| Both exact and fuzzy parameters sent   | `400`                                                                                  |

Two different companies that share a prefix ("Anomaly Bio" and "Anomaly Robotics") are reported as ambiguous. Do not guess. Show the candidates to the user.

## Sector filters

`/tracker`, `/signals`, `/alerts` and `/leads` accept `classification` (aliases `sector`, `sectors`) with natural wording, up to four comma-separated terms:

```http theme={"theme":{"light":"github-light-default","dark":"vesper"}}
GET /api/v1/tracker?classification=biotech
GET /api/v1/leads?classification=biotech,robotics
```

The term is mapped onto RaiseGate's company taxonomy. Weak matches are discarded. Results include descendants of matched categories. The response includes `classificationResolution`.

If the classifier is unavailable, matching falls back to text matching against stored category paths and `degradedToTextMatching` is `true`. If nothing matches, the result is an empty list, not an error.

Broad terms work best: `biotech`, `robotics`, `fintech`, `climate tech`, `developer tools`, `healthcare`, `mlops`, `humanoids`. A term containing a generic word can widen the match. `humanoid robots` behaves like `robotics`; use `humanoids` for humanoid companies only.

## Idempotent creation

`POST /api/v1/tracker` accepts an `Idempotency-Key` header (8-200 printable ASCII characters, no spaces). Send a unique key per logical create so network retries are safe. Keys are remembered for 24 hours.

| Situation                                         | Result                                                                                           |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| First request                                     | `201 Created`                                                                                    |
| Same key, same body (a retry)                     | `200` with the original entity, `idempotencyReplayed: true`, header `Idempotency-Replayed: true` |
| Same key, different body                          | `409 idempotency_conflict`                                                                       |
| Same key while the first request is still running | `409 idempotency_in_progress`                                                                    |
| Invalid key format                                | `400 validation_error`                                                                           |

Independently of idempotency, submitting a company that is already tracked reuses and updates the existing entity and returns `200` with `existingEntity: true`. A company counts as already tracked when **two** identifiers match: a founder's LinkedIn plus the company LinkedIn or website, or the company LinkedIn plus the website. A website alone (for example a name-only create sent twice) does not match and creates a second entity, so rely on `Idempotency-Key` to make retries safe.

On MCP, `add_company` accepts an optional `idempotencyKey`. Without one, the server derives a key from the arguments, so an identical retried call returns the first result.

## Rate limits

Limits apply per API key in fixed windows. Every authenticated request counts, including ones rejected later.

| Requests                                 | Limit                                 |
| ---------------------------------------- | ------------------------------------- |
| Reads (`GET`)                            | 600 per minute                        |
| Writes (`POST`, `PATCH`, `DELETE`)       | 120 per minute                        |
| Create a tracked company or track a lead | 200 per hour (also counts as a write) |
| Refresh an entity                        | 60 per hour (also counts as a write)  |
| Draft enrichment (`/tracker/enrich`)     | 60 per hour (also counts as a write)  |

The hourly caps exist because those calls start paid scraping, enrichment and model work.

```http theme={"theme":{"light":"github-light-default","dark":"vesper"}}
HTTP/1.1 429 Too Many Requests
Retry-After: 2806
```

```json theme={"theme":{"light":"github-light-default","dark":"vesper"}}
{
 "error": {
  "code": "rate_limited",
  "message": "Rate limit exceeded: 60 refresh requests per hour for this API key. Retry after 2806s.",
  "details": { "bucket": "refresh", "limit": 60, "windowSeconds": 3600, "retryAfterSeconds": 2806 }
 }
}
```

Wait `Retry-After` seconds before retrying. Counters live in the tracker's Redis; if Redis is unavailable the API keeps serving rather than blocking clients. Checking limits adds roughly 50-150 ms per request.

## Errors

Every error uses the same envelope; `details` is present only when useful.

```json theme={"theme":{"light":"github-light-default","dark":"vesper"}}
{
 "error": {
  "code": "validation_error",
  "message": "companyWebsite: Invalid url; alertEmails.0: Invalid email"
 }
}
```

Validation messages list every failing field as `path: reason`, so a client can fix everything in one round trip. Full table: [Error reference](/errors).
