# API Quickstart

This guide gets you from zero to your first API call in under five minutes. It covers authentication, creating a ticket, handling errors, and rate limiting.

## Base URL

| Context | Base URL |
|---------|----------|
| External integrations (API key) | `https://api.fesk.io/v1/` |
| Browser SPA (session) | `https://fesk.io/api/` |

External (API-key) integrations are served from the **`api.` subdomain** with an explicit version prefix, for example `https://api.fesk.io/v1/tickets`. API keys only work on this subdomain; sending a key to the main domain returns `401 "API key authentication is only available on the API subdomain"`. The cookie-based SPA, by contrast, runs on the main domain under `/api/`.

All endpoints return JSON. Requests that send a body must use `Content-Type: application/json`.

## Authentication

Fesk supports two authentication methods:

### API Key (recommended for integrations)

Generate an API key from **Settings → API Keys** (requires `admin` role). The key is shown once, store it securely.

Include it in every request as a Bearer token:

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/tickets
```

API key requests go to the `api.` subdomain with the `/v1/` version prefix and do not require CSRF tokens.

> **Project scope is required.** Every ticket, comment, attachment, ticket-type, and board endpoint operates within a single project. Use a project-scoped API key (recommended), or identify the project on the request with either a numeric `?project_id=<id>` or a human-readable `?project=<slug>` (discover slugs via `GET /v1/projects`). A key with no project scope and neither parameter receives `400 "Project ID is required."`, this applies to reads (e.g. listing tickets) as well as writes.

### Session (browser-based)

The SPA frontend uses cookie-based sessions. For session-authenticated requests that modify state (POST, PUT, PATCH, DELETE), you must include a CSRF token.

Fetch a CSRF token first:

```bash
curl -c cookies.txt https://fesk.io/api/csrf-token
```

```json
{
  "ok": true,
  "data": { "token": "csrf_abc123..." }
}
```

Include the token in subsequent requests:

```bash
curl -b cookies.txt \
  -H "X-CSRF-Token: csrf_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"ticket_type_id": 1, "title": "Test"}' \
  https://fesk.io/api/tickets
```

## Response Format

Every response follows a consistent envelope:

**Success:**
```json
{
  "ok": true,
  "data": { ... }
}
```

**Error:**
```json
{
  "ok": false,
  "error": {
    "code": 422,
    "message": "Validation failed",
    "validation_errors": {
      "title": ["Title is required"]
    }
  }
}
```

Every response includes an `X-Correlation-ID` header (e.g., `req_a1b2c3`) useful for debugging, include it when reporting issues.

## Your First API Call

List your tickets:

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/tickets
```

```json
{
  "ok": true,
  "data": {
    "tickets": [
      {
        "id": 1,
        "project_id": 1,
        "ticket_type_id": 1,
        "project_ref_id": 1,
        "ticket_number": "PRJ-1",
        "title": "Welcome to Fesk",
        "description": null,
        "status": "open",
        "status_label": "Open",
        "priority": "medium",
        "priority_label": "Medium",
        "assigned_to": null,
        "created_by": 1,
        "updated_by": null,
        "custom_fields": {},
        "source_type": "web",
        "merged_into_id": null,
        "version": 1,
        "created_at": "2026-04-01T10:00:00+00:00",
        "updated_at": "2026-04-01T10:00:00+00:00"
      }
    ],
    "pagination": {
      "total": 1,
      "page": 1,
      "per_page": 50,
      "total_pages": 1
    }
  }
}
```

## Pagination

The ticket list endpoint supports offset-based pagination.

### Offset Pagination

Use `page` and `per_page` query parameters:

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  "https://api.fesk.io/v1/tickets?page=2&per_page=25"
```

Response includes pagination metadata:

```json
{
  "ok": true,
  "data": {
    "tickets": [...],
    "pagination": { "total": 142, "page": 2, "per_page": 25, "total_pages": 6 }
  }
}
```

Defaults: `page=1`, `per_page=50`. Maximum `per_page` is 100.

### Filtering

The list endpoint supports filtering via query parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `q` | string | Search query (searches title and description) |
| `status` | string | Filter by status slug. Defaults: `open`, `work_in_progress`, `waiting`, `closed`. Projects with custom workflows may define additional statuses, use the `/v1/board` endpoint to discover available lanes. |
| `priority` | string | Filter by priority: `low`, `medium`, `high`, `critical` |
| `assignee` | integer | Filter by assigned user ID |
| `sort` | string | Sort field with direction prefix: `+created_at` (asc) or `-created_at` (desc, default). Allowed fields: `id`, `title`, `status`, `priority`, `created_at`, `updated_at`, `assigned_to` |

> **Note:** When using the `q` search parameter, pagination is not applied, all matching results are returned directly without `pagination` metadata.

## Creating a Ticket

```bash
curl -X POST \
  -H "Authorization: Bearer ld_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "ticket_type_id": 1,
    "title": "Login page returns 500 error",
    "description": "Users see a server error when submitting the login form. Steps: 1. Go to /login, 2. Enter credentials, 3. Click Sign In. Expected: redirect to dashboard. Actual: 500 error page.",
    "priority": "high"
  }' \
  https://api.fesk.io/v1/tickets
```

```json
{
  "ok": true,
  "data": {
    "ticket": {
      "id": 42,
      "ticket_type_id": 1,
      "title": "Login page returns 500 error",
      "status": "open",
      "priority": "high",
      "assigned_to": null,
      "created_at": "2026-04-11T14:30:00Z",
      "updated_at": "2026-04-11T14:30:00Z"
    }
  }
}
```

### Required Fields

| Field | Type | Description |
|-------|------|-------------|
| `ticket_type_id` | integer | ID of the ticket type (must reference an existing type in your project) |
| `title` | string | Ticket subject (max 200 characters) |

### Optional Fields

| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Detailed description (max 5000 characters, markdown supported) |
| `priority` | string | `critical`, `high`, `medium`, `low` (default: `medium`) |
| `assigned_to` | integer | User ID to assign the ticket to |
| `custom_fields` | object | Key-value pairs for custom fields configured on the ticket type |

> **Note:** The API key must have `tickets:write` scope. Project context is resolved from (in order): the API key's project scope, a numeric `project_id` (query or body), or a `project` slug (query or body, e.g. `?project=infrastructure`). A project-scoped API key is recommended, in that case neither parameter is needed.

### Attaching Files

Attachments cannot be included inline during ticket creation. Upload them separately after the ticket exists:

```bash
curl -X POST \
  -H "Authorization: Bearer ld_your_api_key_here" \
  -F "file=@screenshot.png" \
  https://api.fesk.io/v1/tickets/42/attachments
```

```json
{
  "ok": true,
  "data": {
    "attachment": {
      "id": 7,
      "ticket_id": 42,
      "original_filename": "screenshot.png",
      "mime_type": "image/png",
      "size_bytes": 84521,
      "created_at": "2026-04-11T14:31:00+00:00"
    }
  }
}
```

The upload uses `multipart/form-data` with the file in a field named `file`. Requires `tickets:write` scope.

### Downloading Attachments

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  -o screenshot.png \
  https://api.fesk.io/v1/attachments/7/download
```

The download endpoint streams the file binary directly (not a redirect or signed URL). Response headers:

| Header | Example |
|--------|---------|
| `Content-Type` | `image/png` (actual mime type of the file) |
| `Content-Disposition` | `attachment; filename="screenshot.png"; filename*=UTF-8''screenshot.png` |
| `Content-Length` | `84521` |
| `Cache-Control` | `private, no-cache` |

The response body is the raw file bytes. Use the `Content-Disposition` header to extract the original filename for saving.

## Comments

### Adding a Comment

```bash
curl -X POST \
  -H "Authorization: Bearer ld_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "We have identified the root cause. A fix will be deployed shortly.",
    "is_internal": false
  }' \
  https://api.fesk.io/v1/tickets/42/comments
```

```json
{
  "ok": true,
  "data": {
    "comment": {
      "id": 15,
      "ticket_id": 42,
      "user_id": 5,
      "content": "We have identified the root cause. A fix will be deployed shortly.",
      "content_html": null,
      "is_internal": false,
      "created_at": "2026-04-11T15:10:00+00:00",
      "updated_at": "2026-04-11T15:10:00+00:00"
    }
  }
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | Yes | Comment text (max 50,000 characters) |
| `content_html` | string | No | Rich HTML content (sanitized server-side) |
| `is_internal` | boolean | No | `true` for internal note (team-only, default), `false` for customer-visible |

### Listing Comments

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  "https://api.fesk.io/v1/tickets/42/comments"
```

Use `?include_internal=false` to exclude internal notes (e.g. for customer-facing integrations).

> **Note:** The comments endpoint returns up to **1,000 comments** for a ticket in chronological order (oldest first). If a ticket exceeds this limit, comments beyond the oldest 1,000 cannot be retrieved via this endpoint.

## Deleting a Ticket

```bash
curl -X DELETE \
  -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/tickets/42
```

Requires `tickets:delete` scope. This performs a soft delete, the ticket is marked as deleted but not physically removed.

## Listing Projects

You don't need to know internal project IDs. Call `GET /v1/projects` with any valid key to discover the projects it can access, each with a human-readable `slug`:

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/projects
```

```json
{
  "ok": true,
  "data": {
    "projects": [
      { "id": 3, "name": "Infrastructure", "slug": "infrastructure", "description": "Cloud platform tickets" }
    ],
    "count": 1
  }
}
```

Pass the `slug` as `?project=<slug>` anywhere a project is required, instead of the numeric `project_id`. This endpoint needs only a valid API key (no specific scope); a project-scoped key returns just its own project.

## Listing Ticket Types

Before creating a ticket, you need a valid `ticket_type_id`. List available types for your project (accepts `?project_id=<id>` or `?project=<slug>`):

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/ticket-types
```

```json
{
  "ok": true,
  "data": {
    "ticket_types": [
      {
        "id": 1,
        "name": "Bug Report",
        "slug": "bug-report",
        "description": "For reporting software bugs",
        "is_default": false,
        "field_schema": [
          {
            "name": "steps_to_reproduce",
            "label": "Steps to Reproduce",
            "type": "textarea",
            "required": true,
            "constraints": { "max_length": 2000 },
            "default_value": null,
            "order": 1
          },
          {
            "name": "severity",
            "label": "Severity",
            "type": "select",
            "required": true,
            "constraints": { "allowed_values": ["cosmetic", "minor", "major", "blocker"] },
            "default_value": "minor",
            "order": 2
          },
          {
            "name": "affected_users",
            "label": "Affected Users",
            "type": "number",
            "required": false,
            "constraints": { "min_value": 0 },
            "default_value": null,
            "order": 3
          }
        ]
      }
    ],
    "count": 1
  }
}
```

### Field Schema Structure

Each entry in `field_schema` defines a custom field:

| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Field identifier (used as key in `custom_fields`) |
| `label` | string | Human-readable display label |
| `type` | string | Data type: `text`, `textarea`, `number`, `date`, `datetime`, `select`, `multi_select`, `boolean`, `email`, `url` |
| `required` | boolean | Whether the field is required |
| `constraints` | object | Validation constraints (varies by type, see below) |
| `default_value` | mixed | Default value when not provided |
| `order` | integer | Display order (lower = first) |

### Constraints by Field Type

| Field Type | Available Constraints |
|------------|---------------------|
| `text` | `min_length`, `max_length`, `pattern` (regex) |
| `textarea` | `min_length`, `max_length` |
| `number` | `min_value`, `max_value` |
| `select` | `allowed_values` (required) |
| `multi_select` | `allowed_values` (required) |
| `date`, `datetime`, `boolean`, `email`, `url` | No additional constraints |

When creating or updating a ticket, populate `custom_fields` using the `name` as key:

```json
{
  "ticket_type_id": 1,
  "title": "Button misaligned on mobile",
  "custom_fields": {
    "steps_to_reproduce": "1. Open on iPhone 14\n2. Tap Settings\n3. Observe button overlap",
    "severity": "minor",
    "affected_users": 150
  }
}
```

## Listing Users

List users in your account (useful for populating `assigned_to`):

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/users
```

```json
{
  "ok": true,
  "data": {
    "users": [
      { "id": 5, "name": "Jane Smith", "email": "jane@example.com", "role": "agent", "is_active": true }
    ],
    "count": 1
  }
}
```

## Webhooks

Subscribe to events so your integration is notified when things happen.

### Creating a Webhook

```bash
curl -X POST \
  -H "Authorization: Bearer ld_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/fesk-webhook",
    "events": ["ticket.created", "ticket.updated", "comment.created"],
    "project_id": 1
  }' \
  https://api.fesk.io/v1/webhooks
```

The `project_id` field is required and scopes the webhook to a single project. The response includes the signing `secret`, this is the **only** time it is returned, so store it now. (The list endpoint never echoes it back.) You may also supply your own `secret` in the request body instead of letting the server generate one.

```json
{
  "ok": true,
  "data": {
    "webhook": {
      "id": 3,
      "url": "https://your-server.com/fesk-webhook",
      "events": ["ticket.created", "ticket.updated", "comment.created"],
      "secret": "a1b2c3d4e5f6...",
      "is_active": true,
      "created_at": "2026-04-11T16:00:00+00:00"
    }
  }
}
```

Available events: `ticket.created`, `ticket.updated`, `ticket.deleted`, `ticket.status_changed`, `ticket.assigned`, `ticket.merged`, `comment.created`.

### Delivery Guarantees & Retry Behavior

Fesk uses **best-effort delivery with retries** when the async delivery queue is active (production default), or **single-attempt, no-retry** when no queue is configured. This means:
- When the queue is active, events are retried on failure, but delivery is not guaranteed, events may be permanently lost if the queue is unavailable at enqueue time or all retry attempts are exhausted.
- Your endpoint should be **idempotent**: duplicate deliveries are possible in failure/recovery scenarios.

**Retry policy:**
- Delivery timeout: **10 seconds** per attempt
- Maximum retries: **2** (3 total attempts including the initial delivery)
- Retry delays: **Exponential backoff** starting at 100ms (×2 with jitter), approximately 50–100ms, then 100–200ms
- A delivery is considered failed if your endpoint returns a non-2xx status code or the connection times out

**After all retries are exhausted:**
- The delivery is logged as permanently failed
- The webhook subscription remains active, future events will still be delivered
- There is no automatic disabling of webhooks after repeated failures (the subscription stays active)
- There is currently no replay/redelivery API, if you miss events, you need to poll the relevant endpoints to reconcile state

**Event ordering:** Events are delivered roughly in order, but **ordering is not guaranteed** across concurrent deliveries. If your integration requires strict ordering, use the `timestamp` field and the resource's `version`/`updated_at` to determine which state is more recent.

Each delivery includes an `X-Webhook-Signature` HMAC-SHA256 header for verification.

### Webhook Delivery Payload

When an event occurs, Fesk sends an HTTP POST to your URL with this structure:

```json
{
  "event": "ticket.created",
  "timestamp": 1717600000,
  "data": {
    "id": 42,
    "project_id": 1,
    "ticket_type_id": 1,
    "ticket_number": "PRJ-42",
    "title": "Login page returns 500 error",
    "status": "open",
    "priority": "high",
    "assigned_to": null,
    "created_by": 1,
    "updated_by": null,
    "version": 1,
    "created_at": "2026-04-11T14:30:00+00:00",
    "updated_at": "2026-04-11T14:30:00+00:00"
  }
}
```

For `comment.created` events, `data` contains the comment object:

```json
{
  "event": "comment.created",
  "timestamp": 1717600100,
  "data": {
    "id": 15,
    "ticket_id": 42,
    "user_id": 5,
    "content": "We have identified the root cause.",
    "is_internal": false,
    "created_at": "2026-04-11T15:10:00+00:00"
  }
}
```

### Delivery Headers

| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json` |
| `X-Webhook-Signature` | HMAC-SHA256 hex digest of the request body |
| `X-Webhook-Event` | The event type (e.g., `ticket.created`) |

### Verifying Webhook Signatures

Compute the HMAC-SHA256 of the raw request body using the secret returned at webhook creation:

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// In your webhook handler:
app.post('/fesk-webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const isValid = verifyWebhookSignature(req.rawBody, signature, WEBHOOK_SECRET);
  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }
  // Process the event...
  const { event, timestamp, data } = req.body;
  console.log(`Received ${event} at ${new Date(timestamp * 1000)}`);
  res.status(200).send('OK');
});
```

```python
import hmac
import hashlib

def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

## Updating a Ticket

Update one or more properties of an existing ticket. Only fields you include in the request body are changed; omitted fields stay as-is.

```bash
curl -X PUT \
  -H "Authorization: Bearer ld_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"priority": "critical", "status": "work_in_progress"}' \
  https://api.fesk.io/v1/tickets/42
```

```json
{
  "ok": true,
  "data": {
    "ticket": {
      "id": 42,
      "project_id": 1,
      "ticket_type_id": 1,
      "project_ref_id": 12,
      "ticket_number": "PRJ-12",
      "title": "Login page returns 500 error",
      "status": "work_in_progress",
      "status_label": "Work In Progress",
      "priority": "critical",
      "priority_label": "Critical",
      "assigned_to": null,
      "created_by": 5,
      "updated_by": 5,
      "custom_fields": {},
      "source_type": "api",
      "merged_into_id": null,
      "version": 2,
      "created_at": "2026-04-11T14:30:00+00:00",
      "updated_at": "2026-04-11T15:02:00+00:00"
    }
  }
}
```

### Updatable Fields

| Field | Type | Description |
|-------|------|-------------|
| `title` | string | New title (max 200 characters, cannot be empty) |
| `description` | string | New description (max 5000 characters) |
| `status` | string | Status slug. Defaults: `open`, `work_in_progress`, `waiting`, `closed`. Projects with custom workflows may define additional statuses. |
| `priority` | string | `critical`, `high`, `medium`, `low` |
| `assigned_to` | integer | User ID to assign. Send `0` to unassign. Omitting the field (or sending `null`) leaves the current assignee unchanged. |
| `custom_fields` | object | Custom field key-value pairs (merged with existing values) |
| `version` | integer | Expected version for optimistic concurrency (see below). When provided, server rejects update with 409 if ticket has moved past this version. When omitted, no client-side conflict detection (rare simultaneous writes may still produce 409). |

### Optimistic Concurrency (Versioning)

Every ticket has a `version` field (integer, starting at 1) that increments on each successful update. This provides optimistic concurrency control.

**How it works:**

1. You `GET` a ticket → response includes `"version": 3`
2. You `PUT` an update with `"version": 3` in the body → the server verifies the ticket is still at version 3, increments to 4, and returns the updated ticket
3. If another client updated the ticket between your GET and PUT (moving it to version 4), your PUT receives `409 Conflict`

**Usage:**

Include the `version` field in your PUT request body to enable conflict detection:

```bash
curl -X PUT \
  -H "Authorization: Bearer ld_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"priority": "critical", "version": 3}' \
  https://api.fesk.io/v1/tickets/42
```

When a conflict is detected:

```json
{
  "ok": false,
  "error": {
    "code": 409,
    "message": "Ticket was modified by another user"
  }
}
```

**Handling 409 conflicts:**
1. Re-fetch the ticket (`GET /tickets/{id}`) to get the latest state and version
2. Re-apply your intended changes to the fresh data
3. Retry the `PUT` request with the new version

> **Note:** The `version` field is optional in the request body. When omitted, the server does not perform client-side conflict detection, but a `409` may still occur in rare cases of truly simultaneous writes (the storage layer always guards against corrupt partial writes). For production integrations handling concurrent users, always include `version` to reliably detect lost updates. The version check only applies to PUT updates on tickets, comments, webhooks, and attachments do not use optimistic concurrency.

## Reading a Ticket

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  https://api.fesk.io/v1/tickets/42
```

```json
{
  "ok": true,
  "data": {
    "ticket": {
      "id": 42,
      "project_id": 1,
      "ticket_type_id": 1,
      "project_ref_id": 12,
      "ticket_number": "PRJ-12",
      "title": "Login page returns 500 error",
      "description": "Users see a server error...",
      "status": "open",
      "status_label": "Open",
      "priority": "high",
      "priority_label": "High",
      "assigned_to": null,
      "created_by": 5,
      "updated_by": null,
      "custom_fields": { "description": "Users see a server error..." },
      "source_type": "api",
      "merged_into_id": null,
      "version": 1,
      "created_at": "2026-04-11T14:30:00+00:00",
      "updated_at": "2026-04-11T14:30:00+00:00"
    }
  }
}
```

If the ticket has been merged into another, the response also includes `"merged_into"` with the target ticket ID.

## Error Handling

| HTTP Status | Meaning | Action |
|-------------|---------|--------|
| `400` | Bad request, malformed JSON or invalid parameters | Fix the request body |
| `401` | Unauthorized, missing or invalid API key / session | Check your credentials |
| `402` | Payment required, free API allowance exhausted or monthly call limit reached | Enable paid API usage or raise the call limit in billing settings |
| `403` | Forbidden, insufficient permissions (API key lacks required scope) | Verify your key has the required scope |
| `404` | Not found, resource does not exist or is outside your project scope | Check the ID or path |
| `409` | Conflict, concurrent modification (version mismatch) | Re-fetch the resource and retry |
| `422` | Validation error, check `error.validation_errors` | Fix the flagged fields |
| `429` | Rate limited, too many requests | Back off and retry (see below) |
| `500` | Server error | Retry with backoff; report if persistent |

### Handling Errors in Code

```javascript
const response = await fetch('https://api.fesk.io/v1/tickets', {
  headers: { 'Authorization': 'Bearer ld_your_api_key_here' }
});

const body = await response.json();

if (!body.ok) {
  console.error(`Error ${body.error.code}: ${body.error.message}`);
  if (body.error.validation_errors) {
    // Field-level validation errors have array values; billing/structured
    // errors (e.g. 402) may have scalar values. Handle both gracefully.
    for (const [field, value] of Object.entries(body.error.validation_errors)) {
      const detail = Array.isArray(value) ? value.join(', ') : String(value);
      console.error(`  ${field}: ${detail}`);
    }
  }
}
```

## Rate Limiting

Fesk enforces sliding-window rate limits on all API endpoints. When you exceed the limit, you receive a `429 Too Many Requests` response with a `Retry-After` header indicating how many seconds to wait before retrying.

### Rate Limit Tiers

| Authentication Type | Limit | Window |
|---------------------|-------|--------|
| API key (authenticated) | 100 requests | 60 seconds |
| Session (authenticated) | 250 requests | 60 seconds |
| Anonymous (no auth) | 30 requests | 60 seconds |
| Auth endpoints (login/register) | 10 requests | 60 seconds |

### Per-Route Overrides

Some endpoints have stricter limits to protect expensive operations:

| Endpoint | Limit | Window |
|----------|-------|--------|
| `/search/*` | 10 requests | 60 seconds |
| `/ai/suggest-reply` | 30 requests | 1 hour |
| `/search/ai` | 60 requests | 1 hour |
| `/auth/forgot-password` | 5 requests | 1 hour |
| `/auth/reset-password` | 10 requests | 1 hour |
| `/me/data-export` | 3 requests | 1 hour |
| `/client-errors` | 30 requests | 60 seconds |
| `/tickets/*/status` | 200 requests | 60 seconds |

### Rate Limit Headers

Every response includes rate limit information:

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `X-RateLimit-Reset` | Unix timestamp when the window resets |
| `Retry-After` | Seconds to wait before retrying (sent on `429` responses) |

### Recommended Retry Strategy

Use exponential backoff with jitter:

```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxRetries) {
      throw new Error('Rate limit exceeded after retries');
    }

    // Prefer the server's Retry-After (in seconds); otherwise exponential backoff + jitter
    const retryAfter = Number(response.headers.get('Retry-After'));
    const delay = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : Math.pow(2, attempt) * 1000 + Math.random() * 500;
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}
```

### Best Practices

- Cache responses where possible to reduce request volume
- Spread requests over time rather than bursting
- Monitor for `429` responses and adjust your request rate

## CORS (Cross-Origin Resource Sharing)

The API supports CORS for browser-based integrations from different origins.

### CORS Headers

| Header | Value |
|--------|-------|
| `Access-Control-Allow-Methods` | GET, POST, PUT, PATCH, DELETE, OPTIONS |
| `Access-Control-Allow-Headers` | Content-Type, X-CSRF-Token, X-API-Key, Authorization, Idempotency-Key |
| `Access-Control-Expose-Headers` | X-Correlation-ID, X-CSRF-Token, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Idempotency-Key, X-Idempotency-Replay |
| `Access-Control-Max-Age` | 86400 (24 hours preflight cache) |

### Configuration

Allowed origins are configured per deployment. When specific origins are configured:
- Only listed origins receive CORS headers
- Credentials (cookies) are supported

In wildcard mode (development):
- All origins are allowed (`*`)
- Credentials are automatically disabled (per CORS spec)

If your browser-based integration receives CORS errors, contact your administrator to add your domain to the allowed origins list.

## Available Endpoints

### External API (API key auth, `api.` subdomain, `/v1/` prefix)

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| `GET` | `/v1/projects` | none (any valid key) | List accessible projects with their slugs |
| `GET` | `/v1/tickets` | `tickets:read` | List tickets (offset pagination, filtering, search) |
| `POST` | `/v1/tickets` | `tickets:write` | Create ticket |
| `GET` | `/v1/tickets/{id}` | `tickets:read` | Get ticket |
| `PUT` | `/v1/tickets/{id}` | `tickets:write` | Update ticket |
| `PATCH` | `/v1/tickets/{id}/status` | `tickets:write` | Update ticket status (optional `after_id`/`before_id` positioning, `resolution_type`) |
| `POST` | `/v1/tickets/bulk-update` | `tickets:write` | Update up to 100 tickets in one request (all must be in the key's project) |
| `DELETE` | `/v1/tickets/{id}` | `tickets:delete` | Delete ticket (soft delete) |
| `GET` | `/v1/tickets/{id}/attachments` | `tickets:read` | List attachments for a ticket |
| `POST` | `/v1/tickets/{id}/attachments` | `tickets:write` | Upload attachment (multipart/form-data) |
| `GET` | `/v1/attachments/{id}/download` | `tickets:read` | Download an attachment |
| `DELETE` | `/v1/attachments/{id}` | `tickets:write` | Delete an attachment |
| `GET` | `/v1/tickets/{id}/comments` | `comments:read` | List comments on a ticket |
| `POST` | `/v1/tickets/{id}/comments` | `comments:write` | Add a comment to a ticket |
| `GET` | `/v1/comments/{id}` | `comments:read` | Get a single comment |
| `PUT` | `/v1/comments/{id}` | `comments:write` | Update a comment |
| `DELETE` | `/v1/comments/{id}` | `comments:write` | Delete a comment |
| `GET` | `/v1/board` | `tickets:read` | Board view (tickets grouped by status lanes) |
| `GET` | `/v1/ticket-types` | `tickets:read` | List available ticket types for a project |
| `GET` | `/v1/users` | `tickets:read` | List users in your account (for assignment) |
| `GET` | `/v1/webhooks` | `tickets:read` | List webhook subscriptions |
| `POST` | `/v1/webhooks` | `tickets:write` | Create a webhook subscription |
| `DELETE` | `/v1/webhooks/{id}` | `tickets:write` | Delete a webhook subscription |

### Board Endpoint

The board endpoint returns tickets grouped by status for swimlane display:

```bash
curl -H "Authorization: Bearer ld_your_api_key_here" \
  "https://api.fesk.io/v1/board?priority=high"
```

```json
{
  "ok": true,
  "data": {
    "lanes": {
      "open": [...],
      "work_in_progress": [...],
      "waiting": [...],
      "closed": [...]
    },
    "counts": {
      "open": 5,
      "work_in_progress": 3,
      "waiting": 2,
      "closed": 12,
      "total": 22
    }
  }
}
```

The lane keys match the project's workflow status slugs. The defaults are `open`, `work_in_progress`, `waiting`, and `closed`, but projects with custom workflows may include additional lanes. Clients should iterate the `lanes` object dynamically rather than hard-coding the four default keys.

Supports `priority` and `assignee` query parameter filters (same as the list endpoint).

### AI Endpoints (session auth, CSRF required for POST/PUT)

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/ai/suggest-reply` | Generate AI-suggested reply for a ticket |
| `GET` | `/api/ai/suggest-reply/availability` | Check if suggest-reply is available |
| `GET` | `/api/ai/autonomy` | Get guardrails + kill-switch state (admin) |
| `PUT` | `/api/ai/autonomy/guardrails` | Update guardrails (admin) |
| `GET` | `/api/ai/autonomy/threshold-preview` | Preview threshold impact (admin) |
| `POST` | `/api/ai/autonomy/kill` | Activate kill switch (admin) |
| `POST` | `/api/ai/autonomy/clear-kill` | Clear kill switch (admin) |
| `GET` | `/api/ai/autonomous-actions` | Get recent autonomous actions (agent+) |
| `GET` | `/api/ai/audit-queue` | List autonomous actions for review (admin/team lead) |
| `POST` | `/api/ai/audit-queue/{id}/acknowledge` | Acknowledge an action |
| `POST` | `/api/ai/audit-queue/{id}/revert` | Revert an autonomous action |

### Activity Feed Endpoints (session auth)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/activity` | Paginated activity feed with filters |
| `GET` | `/api/activity/new` | Poll for new events since cursor |

## API Versioning & Stability

The Fesk API uses URL-based versioning (`/v1/`). The current version is **v1**.

**Stability guarantees:**
- v1 endpoints are **stable**: existing fields, parameters, and response shapes will not change in breaking ways
- **Additive changes** (new optional fields in responses, new optional query parameters, new endpoints) may be introduced without a version bump
- **Breaking changes** (removing fields, changing types, renaming endpoints, changing required parameters) will result in a new version (`/api/v2/`)
- Deprecated features will be announced at least 90 days before removal

**What counts as non-breaking (no version bump):**
- Adding new optional properties to response objects
- Adding new optional query parameters or request body fields
- Adding new endpoints
- Adding new webhook event types
- Adding new enum values to fields like `status` or `priority` (clients should handle unknown values gracefully)

**Recommendation:** Design your client to ignore unknown JSON properties rather than failing on them. This ensures forward compatibility as the API evolves.

## Idempotency

The Fesk API supports idempotency keys for POST requests.

Send an `Idempotency-Key` header with a stable unique value for each create operation:

```bash
curl -X POST https://api.fesk.io/api/v1/tickets \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f6d7f3f-7f6d-4d20-9c7d-74215c5c8b62" \
  -d '{"title":"Cannot export CSV","project_id":1,"ticket_type_id":2}'
```

If the same authenticated user or API key retries the same POST with the same path, key, and payload, Fesk returns the cached successful response instead of creating a duplicate. Replayed responses include `X-Idempotency-Replay: true`. Idempotency entries are retained for the normal retry window, currently 1 hour.

If the same key is reused with a different payload, Fesk returns `409 Conflict`.

> **Note:** PUT (update) and DELETE operations are naturally idempotent, repeating them produces the same result. Only POST (create) operations carry duplication risk.