# REST API

The Frihet REST API lets you access and manipulate the resources in your account programmatically. All communication is over HTTPS and responses are JSON.

## Base URL

```
https://api.frihet.io/v1
```

All endpoints in this reference are relative to this base URL.

**Discovery:** A `GET` request to the root (`https://api.frihet.io/`) returns the main links without authentication:

```json
{
  "name": "Frihet API",
  "version": "1.0.0",
  "docs": "https://docs.frihet.io/desarrolladores/api-rest",
  "openapi": "https://api.frihet.io/openapi.yaml",
  "mcp": "https://mcp.frihet.io",
  "status": "https://status.frihet.io"
}
```

The OpenAPI 3.1 specification is available at `https://api.frihet.io/openapi.yaml`.

:::tip SDK available
For TypeScript/JavaScript, the official SDK simplifies integration:

```bash
npm install @frihet/sdk
```

```typescript
const frihet = new Frihet({ apiKey: 'fri_...' });
const invoices = await frihet.invoices.list({ status: 'overdue' });
```

Repository: [github.com/Frihet-io/frihet-sdk](https://github.com/Frihet-io/frihet-sdk)
:::

---

## Authentication {#autenticacion}

Every request must include an API key in the `X-API-Key` header. Keys are created from **Settings > Developers > API Keys** in the Frihet dashboard.

Keys use the prefix `fri_` followed by 32 random bytes encoded in base64url. Example: `fri_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345678`.

```bash
curl https://api.frihet.io/v1/clients \
  -H "X-API-Key: fri_your-key-here"
```

Alternatively, you can send the key as a Bearer token in the `Authorization` header:

```bash
curl https://api.frihet.io/v1/clients \
  -H "Authorization: Bearer fri_your-key-here"
```

### Key security

- The plaintext key is shown **only once**, at creation time. It cannot be recovered afterward.
- The server stores a **SHA-256 hash** of the key. Even in the event of a data breach, the original key is not recoverable.
- You can create keys with a **configurable expiration date**.
- If you suspect a key has been compromised, revoke it immediately from the dashboard.

### Key lifecycle

**Creating a key:**

1. Go to **Settings > Developers > API Keys**
2. Click **Create key**
3. Assign a descriptive name (e.g., `accounting-integration`)
4. Optionally, set an **expiration date** in days. If left empty, the key does not expire
5. Copy the key immediately -- you will not be able to see it again

**Expiration:**

Keys with an expiration date stop working automatically when the date is reached. Requests with an expired key receive a `401 Unauthorized`.

**Revocation:**

You can revoke a key at any time from **Settings > Developers > API Keys**. Revocation is **immediate and irreversible**: in-flight requests with that key will fail from that moment on.

**Key rotation:**

To rotate a key without service interruption:

1. Create a new key with the same scope
2. Update your integration to use the new key
3. Verify that requests work correctly
4. Revoke the old key

**Monitoring:**

Each key shows the **last used** date in the Settings panel. Periodically review inactive keys and revoke those no longer in use.

---

## Rate limiting

Each API key has a limit of **100 requests per minute**. If exceeded, the API responds with a `429`:

```json
{
  "error": "Rate limit exceeded",
  "message": "Maximum 100 requests per minute",
  "retryAfter": 60
}
```

### Rate limiting headers

All API responses include headers so you can manage the limit proactively:

| Header | Description | Example |
|--------|-------------|---------|
| `X-RateLimit-Limit` | Allowed requests per minute | `100` |
| `X-RateLimit-Remaining` | Remaining requests in the current window | `87` |
| `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets | `1709312400` |

**Example response with rate limiting headers:**

```
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1709312400
Content-Type: application/json
```

**Handling 429 responses:**

When you receive a `429`, use the headers to calculate how long to wait before retrying:

```javascript
async function fetchWithRateLimit(url, options) {
  const response = await fetch(url, options);

  if (response.status === 429) {
    const resetTimestamp = response.headers.get('X-RateLimit-Reset');
    const waitMs = (Number(resetTimestamp) * 1000) - Date.now();
    await new Promise(resolve => setTimeout(resolve, Math.max(waitMs, 1000)));
    return fetch(url, options);
  }

  return response;
}
```

**Recommendations:**

- If you receive a `429`, wait until the timestamp indicated in `X-RateLimit-Reset` before retrying.
- Monitor `X-RateLimit-Remaining` to throttle requests before hitting the limit.
- Spread requests over time rather than sending bursts.

---

## Request size

The body of POST, PUT, and PATCH requests cannot exceed **1 MB**. Larger requests receive a `413`.

---

## Resources

The API exposes 7 main resources: invoices, expenses, clients, products, quotes, vendors, and webhooks. All support full CRUD operations (GET, POST, PUT/PATCH, DELETE). Additionally, clients have CRM subcollections: contacts, activities, and notes.

:::info PATCH vs PUT
Both `PUT` and `PATCH` accept partial updates. You do not need to send the full resource -- only the fields you want to modify.
:::

### Invoices (`/invoices`)

#### List invoices

```
GET /v1/invoices
```

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 50 | Results per page (maximum 100) |
| `offset` | integer | 0 | Number of results to skip (maximum 10,000) |
| `status` | string | -- | Filter by status: `draft`, `sent`, `paid`, `overdue`, `cancelled` |
| `from` | string | -- | Start date (ISO 8601: `YYYY-MM-DD`). Filters by `issueDate` |
| `to` | string | -- | End date (ISO 8601: `YYYY-MM-DD`). Filters by `issueDate` |

**Example:**

```bash
curl "https://api.frihet.io/v1/invoices?limit=10&status=paid&from=2026-01-01&to=2026-03-31" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": [
    {
      "id": "abc123",
      "clientName": "Acme S.L.",
      "items": [
        { "description": "Consultoria", "quantity": 10, "unitPrice": 75 }
      ],
      "status": "paid",
      "issueDate": "2026-01-15",
      "dueDate": "2026-02-15",
      "taxRate": 21,
      "notes": "",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-20T14:00:00.000Z"
    }
  ],
  "total": 42,
  "limit": 10,
  "offset": 0
}
```

#### Get invoice

```
GET /v1/invoices/:id
```

```bash
curl https://api.frihet.io/v1/invoices/abc123 \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "id": "abc123",
  "clientName": "Acme S.L.",
  "items": [
    { "description": "Consultoria", "quantity": 10, "unitPrice": 75 }
  ],
  "status": "paid",
  "issueDate": "2026-01-15",
  "dueDate": "2026-02-15",
  "taxRate": 21,
  "notes": "",
  "createdAt": "2026-01-15T10:30:00.000Z",
  "updatedAt": "2026-01-20T14:00:00.000Z"
}
```

#### Create invoice

```
POST /v1/invoices
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `clientName` | string | Client name (max 10,000 characters) |
| `items` | array | Invoice line items. Each item: `{ description, quantity, unitPrice }` |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `draft` (default), `sent`, `paid`, `overdue`, `cancelled` |
| `issueDate` | string | Issue date (ISO 8601). Default: today |
| `dueDate` | string | Due date (ISO 8601) |
| `notes` | string | Internal notes (max 10,000 characters) |
| `taxRate` | number | Tax percentage (0-100). E.g., `21` for 21% VAT |

**Structure of each line item (`items[]`):**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description` | string | Yes | Item description (max 10,000 characters) |
| `quantity` | number | Yes | Quantity |
| `unitPrice` | number | Yes | Unit price |

```bash
curl -X POST https://api.frihet.io/v1/invoices \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "clientName": "Acme S.L.",
    "items": [
      { "description": "Desarrollo web", "quantity": 40, "unitPrice": 60 }
    ],
    "dueDate": "2026-03-01",
    "taxRate": 21,
    "notes": "Proyecto Q1 2026"
  }'
```

**Response (201):**

```json
{
  "id": "def456",
  "clientName": "Acme S.L.",
  "items": [
    { "description": "Desarrollo web", "quantity": 40, "unitPrice": 60 }
  ],
  "status": "draft",
  "issueDate": "2026-02-12",
  "dueDate": "2026-03-01",
  "taxRate": 21,
  "notes": "Proyecto Q1 2026",
  "createdAt": "2026-02-12T09:00:00.000Z",
  "updatedAt": "2026-02-12T09:00:00.000Z"
}
```

#### Update invoice (PUT or PATCH)

```
PUT /v1/invoices/:id
PATCH /v1/invoices/:id
```

You only need to send the fields you want to modify. Fields not included remain unchanged.

```bash
curl -X PATCH https://api.frihet.io/v1/invoices/def456 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "sent",
    "notes": "Enviada al cliente"
  }'
```

**Response (200):** Updated invoice object.

:::caution
If you send `items`, you must send the complete array -- partial updates of individual line items are not supported.
:::

#### Delete invoice

```
DELETE /v1/invoices/:id
```

```bash
curl -X DELETE https://api.frihet.io/v1/invoices/def456 \
  -H "X-API-Key: fri_your-key-here"
```

**Response:** `204 No Content`

#### Download invoice as PDF

```
GET /v1/invoices/:id/pdf
```

Returns the invoice PDF as `application/pdf` with a `Content-Disposition: attachment` header.

```bash
curl -o invoice.pdf https://api.frihet.io/v1/invoices/abc123/pdf \
  -H "X-API-Key: fri_your-key-here"
```

#### Send invoice by email

```
POST /v1/invoices/:id/send
```

Sends the invoice to the specified recipient via Resend. If the invoice is in `draft` status, it is automatically updated to `sent`.

**Fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `recipientEmail` | string | Yes | Recipient email address (max 255 characters) |
| `recipientName` | string | No | Recipient name (max 200 characters) |
| `customMessage` | string | No | Custom message in the email body (max 5,000 characters) |
| `locale` | string | No | Email language: `es` (default) or `en` |

```bash
curl -X POST https://api.frihet.io/v1/invoices/abc123/send \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientEmail": "admin@acme.es",
    "recipientName": "Departamento de Contabilidad",
    "locale": "es"
  }'
```

**Response (200):**

```json
{ "success": true, "messageId": "re_abc123..." }
```

#### Mark invoice as paid

```
POST /v1/invoices/:id/paid
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `paidDate` | string | No | Payment date (ISO 8601). Default: today |

```bash
curl -X POST https://api.frihet.io/v1/invoices/abc123/paid \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{ "paidDate": "2026-03-15" }'
```

**Response (200):**

```json
{ "success": true, "status": "paid", "paidAt": "2026-03-15" }
```

---

### Expenses (`/expenses`)

#### List expenses

```
GET /v1/expenses
```

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 50 | Results per page (maximum 100) |
| `offset` | integer | 0 | Number of results to skip |
| `from` | string | -- | Start date (ISO 8601). Filters by `date` |
| `to` | string | -- | End date (ISO 8601). Filters by `date` |

```bash
curl "https://api.frihet.io/v1/expenses?limit=20&from=2026-01-01" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": [
    {
      "id": "exp789",
      "description": "Licencia Adobe Creative Cloud",
      "amount": 59.99,
      "category": "software",
      "date": "2026-02-01",
      "vendor": "Adobe Inc.",
      "taxDeductible": true,
      "createdAt": "2026-02-01T10:00:00.000Z",
      "updatedAt": "2026-02-01T10:00:00.000Z"
    }
  ],
  "total": 15,
  "limit": 20,
  "offset": 0
}
```

#### Get expense

```
GET /v1/expenses/:id
```

#### Create expense

```
POST /v1/expenses
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Expense description (max 10,000 characters) |
| `amount` | number | Expense amount |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `category` | string | Expense category (max 10,000 characters) |
| `date` | string | Expense date (ISO 8601). Default: today |
| `vendor` | string | Vendor name (max 10,000 characters) |
| `taxDeductible` | boolean | Whether the expense is tax-deductible |

```bash
curl -X POST https://api.frihet.io/v1/expenses \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Licencia Adobe Creative Cloud",
    "amount": 59.99,
    "category": "software",
    "date": "2026-02-01",
    "vendor": "Adobe Inc.",
    "taxDeductible": true
  }'
```

**Response (201):**

```json
{
  "id": "exp789",
  "description": "Licencia Adobe Creative Cloud",
  "amount": 59.99,
  "category": "software",
  "date": "2026-02-01",
  "vendor": "Adobe Inc.",
  "taxDeductible": true,
  "createdAt": "2026-02-12T09:15:00.000Z",
  "updatedAt": "2026-02-12T09:15:00.000Z"
}
```

#### Update expense (PUT or PATCH)

```
PUT /v1/expenses/:id
PATCH /v1/expenses/:id
```

```bash
curl -X PATCH https://api.frihet.io/v1/expenses/exp789 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 65.99, "taxDeductible": false }'
```

**Response (200):** Updated expense object.

#### Delete expense

```
DELETE /v1/expenses/:id
```

**Response:** `204 No Content`

---

### Clients (`/clients`)

#### List clients

```
GET /v1/clients
```

Accepts `limit`, `offset`, `from`, and `to` (filters by `createdAt`).

```bash
curl "https://api.frihet.io/v1/clients?limit=50" \
  -H "X-API-Key: fri_your-key-here"
```

#### Get client

```
GET /v1/clients/:id
```

#### Create client

```
POST /v1/clients
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Client name (max 10,000 characters) |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `email` | string | Contact email |
| `phone` | string | Phone number |
| `taxId` | string | Tax ID (NIF/CIF/VAT) |
| `address` | object | Address (see structure below) |

**`address` structure:**

| Field | Type | Description |
|-------|------|-------------|
| `street` | string | Street and number |
| `city` | string | City |
| `state` | string | State or province |
| `postalCode` | string | Postal code |
| `country` | string | Country |

All `address` fields are optional.

```bash
curl -X POST https://api.frihet.io/v1/clients \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme S.L.",
    "email": "admin@acme.es",
    "taxId": "B12345678",
    "address": {
      "street": "Calle Gran Via 42",
      "city": "Madrid",
      "postalCode": "28013",
      "country": "ES"
    }
  }'
```

**Response (201):**

```json
{
  "id": "cli001",
  "name": "Acme S.L.",
  "email": "admin@acme.es",
  "taxId": "B12345678",
  "address": {
    "street": "Calle Gran Via 42",
    "city": "Madrid",
    "postalCode": "28013",
    "country": "ES"
  },
  "createdAt": "2026-02-12T09:30:00.000Z",
  "updatedAt": "2026-02-12T09:30:00.000Z"
}
```

#### Update client (PUT or PATCH)

```
PUT /v1/clients/:id
PATCH /v1/clients/:id
```

```bash
curl -X PATCH https://api.frihet.io/v1/clients/cli001 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+34 912 345 678" }'
```

**Response (200):** Updated client object.

#### Delete client

```
DELETE /v1/clients/:id
```

**Response:** `204 No Content`

---

### CRM: Contacts, Activities, and Notes

Clients have three subcollections for CRM relationship management: contacts, activities, and notes. All endpoints require a valid `clientId` in the URL.

#### Contacts (`/v1/clients/:id/contacts`)

##### List contacts

```
GET /v1/clients/:id/contacts
```

```bash
curl "https://api.frihet.io/v1/clients/cli001/contacts" \
  -H "X-API-Key: fri_your-key-here"
```

##### Get contact

```
GET /v1/clients/:id/contacts/:contactId
```

```bash
curl "https://api.frihet.io/v1/clients/cli001/contacts/con001" \
  -H "X-API-Key: fri_your-key-here"
```

##### Create contact

```
POST /v1/clients/:id/contacts
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Contact person name |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `email` | string | Contact email |
| `phone` | string | Phone number |
| `role` | string | Job title or role (e.g., "CFO") |
| `isPrimary` | boolean | Whether this is the primary contact for the client |

```bash
curl -X POST https://api.frihet.io/v1/clients/cli001/contacts \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Maria Garcia",
    "email": "maria@acme.es",
    "phone": "+34 612 345 678",
    "role": "CFO",
    "isPrimary": true
  }'
```

**Response (201):**

```json
{
  "id": "con001",
  "name": "Maria Garcia",
  "email": "maria@acme.es",
  "phone": "+34 612 345 678",
  "role": "CFO",
  "isPrimary": true,
  "createdAt": "2026-03-15T10:00:00.000Z",
  "updatedAt": "2026-03-15T10:00:00.000Z"
}
```

##### Update contact

```
PATCH /v1/clients/:id/contacts/:contactId
```

```bash
curl -X PATCH https://api.frihet.io/v1/clients/cli001/contacts/con001 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{ "role": "CEO" }'
```

**Response (200):** Updated contact object.

##### Delete contact

```
DELETE /v1/clients/:id/contacts/:contactId
```

```bash
curl -X DELETE https://api.frihet.io/v1/clients/cli001/contacts/con001 \
  -H "X-API-Key: fri_your-key-here"
```

**Response:** `204 No Content`

#### Activities (`/v1/clients/:id/activities`)

The activity timeline tracks interactions with a client. System activities (like `invoice_created`, `quote_sent`, etc.) are generated automatically. You can also create manual activities.

:::caution Immutable
Activities are immutable. They cannot be updated or deleted once created.
:::

##### List activities

```
GET /v1/clients/:id/activities
```

```bash
curl "https://api.frihet.io/v1/clients/cli001/activities" \
  -H "X-API-Key: fri_your-key-here"
```

##### Get activity

```
GET /v1/clients/:id/activities/:activityId
```

```bash
curl "https://api.frihet.io/v1/clients/cli001/activities/act001" \
  -H "X-API-Key: fri_your-key-here"
```

##### Create activity

```
POST /v1/clients/:id/activities
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `type` | string | Activity type: `call`, `email`, `meeting`, or `task` |
| `title` | string | Descriptive title for the activity |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Detailed description |
| `metadata` | object | Free-form additional data |

:::info Activity types
The types `call`, `email`, `meeting`, and `task` are for manually created activities. System types like `invoice_created`, `quote_sent`, or `expense_linked` are generated automatically and cannot be created via API.
:::

```bash
curl -X POST https://api.frihet.io/v1/clients/cli001/activities \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "call",
    "title": "Follow-up call on Q2 budget",
    "description": "Discussed budget terms. Awaiting confirmation.",
    "metadata": {
      "duration": "15min",
      "outcome": "pending"
    }
  }'
```

**Response (201):**

```json
{
  "id": "act001",
  "type": "call",
  "title": "Follow-up call on Q2 budget",
  "description": "Discussed budget terms. Awaiting confirmation.",
  "metadata": {
    "duration": "15min",
    "outcome": "pending"
  },
  "createdAt": "2026-03-15T14:30:00.000Z"
}
```

#### Notes (`/v1/clients/:id/notes`)

##### List notes

```
GET /v1/clients/:id/notes
```

```bash
curl "https://api.frihet.io/v1/clients/cli001/notes" \
  -H "X-API-Key: fri_your-key-here"
```

##### Get note

```
GET /v1/clients/:id/notes/:noteId
```

```bash
curl "https://api.frihet.io/v1/clients/cli001/notes/note001" \
  -H "X-API-Key: fri_your-key-here"
```

##### Create note

```
POST /v1/clients/:id/notes
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `content` | string | Note content |

```bash
curl -X POST https://api.frihet.io/v1/clients/cli001/notes \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Client interested in Premium plan. Follow up in April for renewal."
  }'
```

**Response (201):**

```json
{
  "id": "note001",
  "content": "Client interested in Premium plan. Follow up in April for renewal.",
  "createdAt": "2026-03-15T16:00:00.000Z",
  "updatedAt": "2026-03-15T16:00:00.000Z"
}
```

##### Update note

```
PATCH /v1/clients/:id/notes/:noteId
```

```bash
curl -X PATCH https://api.frihet.io/v1/clients/cli001/notes/note001 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Client interested in Premium plan. Meeting confirmed April 5th."
  }'
```

**Response (200):** Updated note object.

##### Delete note

```
DELETE /v1/clients/:id/notes/:noteId
```

```bash
curl -X DELETE https://api.frihet.io/v1/clients/cli001/notes/note001 \
  -H "X-API-Key: fri_your-key-here"
```

**Response:** `204 No Content`

---

### Products (`/products`)

#### List products

```
GET /v1/products
```

Accepts `limit`, `offset`, `from`, and `to` (filters by `createdAt`).

#### Get product

```
GET /v1/products/:id
```

#### Create product

```
POST /v1/products
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Product or service name (max 10,000 characters) |
| `unitPrice` | number | Unit price |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Description (max 10,000 characters) |
| `taxRate` | number | Tax percentage (0-100) |

```bash
curl -X POST https://api.frihet.io/v1/products \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Hora de consultoria",
    "unitPrice": 75,
    "description": "Consultoria estrategica",
    "taxRate": 21
  }'
```

**Response (201):**

```json
{
  "id": "prod001",
  "name": "Hora de consultoria",
  "unitPrice": 75,
  "description": "Consultoria estrategica",
  "taxRate": 21,
  "createdAt": "2026-02-12T10:00:00.000Z",
  "updatedAt": "2026-02-12T10:00:00.000Z"
}
```

#### Update product (PUT or PATCH)

```
PUT /v1/products/:id
PATCH /v1/products/:id
```

```bash
curl -X PATCH https://api.frihet.io/v1/products/prod001 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{ "unitPrice": 85 }'
```

**Response (200):** Updated product object.

#### Delete product

```
DELETE /v1/products/:id
```

**Response:** `204 No Content`

---

### Quotes (`/quotes`)

#### List quotes

```
GET /v1/quotes
```

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 50 | Results per page (maximum 100) |
| `offset` | integer | 0 | Number of results to skip |
| `status` | string | -- | Filter by status: `draft`, `sent`, `accepted`, `rejected`, `expired` |
| `from` | string | -- | Start date (ISO 8601). Filters by `issueDate` |
| `to` | string | -- | End date (ISO 8601). Filters by `issueDate` |

```bash
curl "https://api.frihet.io/v1/quotes?status=sent" \
  -H "X-API-Key: fri_your-key-here"
```

#### Get quote

```
GET /v1/quotes/:id
```

#### Create quote

```
POST /v1/quotes
```

**Required fields:**

| Field | Type | Description |
|-------|------|-------------|
| `clientName` | string | Client name (max 10,000 characters) |
| `items` | array | Quote line items. Each item: `{ description, quantity, unitPrice }` |

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `validUntil` | string | Expiration date (ISO 8601) |
| `notes` | string | Notes or terms (max 10,000 characters) |
| `status` | string | `draft` (default), `sent`, `accepted`, `rejected`, `expired` |

```bash
curl -X POST https://api.frihet.io/v1/quotes \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "clientName": "Design Studio SL",
    "items": [
      { "description": "Desarrollo web", "quantity": 80, "unitPrice": 60 },
      { "description": "Diseno UX", "quantity": 20, "unitPrice": 55 }
    ],
    "validUntil": "2026-04-01",
    "notes": "Incluye 2 rondas de revision"
  }'
```

**Response (201):**

```json
{
  "id": "quo001",
  "clientName": "Design Studio SL",
  "items": [
    { "description": "Desarrollo web", "quantity": 80, "unitPrice": 60 },
    { "description": "Diseno UX", "quantity": 20, "unitPrice": 55 }
  ],
  "status": "draft",
  "validUntil": "2026-04-01",
  "notes": "Incluye 2 rondas de revision",
  "createdAt": "2026-02-12T11:00:00.000Z",
  "updatedAt": "2026-02-12T11:00:00.000Z"
}
```

#### Update quote (PUT or PATCH)

```
PUT /v1/quotes/:id
PATCH /v1/quotes/:id
```

#### Delete quote

```
DELETE /v1/quotes/:id
```

**Response:** `204 No Content`

#### Download quote as PDF

```
GET /v1/quotes/:id/pdf
```

Works the same as `/invoices/:id/pdf`. Returns `application/pdf`.

```bash
curl -o quote.pdf https://api.frihet.io/v1/quotes/quo001/pdf \
  -H "X-API-Key: fri_your-key-here"
```

#### Send quote by email

```
POST /v1/quotes/:id/send
```

Same fields as `/invoices/:id/send` (`recipientEmail`, `recipientName`, `customMessage`, `locale`). If the quote is in `draft` status, it is automatically updated to `sent`.

---

## Batch operations (`/batch`)

All main resources support batch creation. Send an array of up to 50 items in a single request.

```
POST /v1/{resource}/batch
```

**Supported resources:** `invoices`, `expenses`, `clients`, `products`, `quotes`

**Request body:**

```json
{
  "items": [
    { "clientName": "Acme SL", "items": [{ "description": "Hora consultoria", "quantity": 1, "unitPrice": 95 }] },
    { "clientName": "TechStart SL", "items": [{ "description": "Desarrollo web", "quantity": 8, "unitPrice": 60 }] }
  ]
}
```

```bash
curl -X POST https://api.frihet.io/v1/invoices/batch \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "clientName": "Acme SL", "items": [{ "description": "Consultoria", "quantity": 1, "unitPrice": 95 }] },
      { "clientName": "TechStart SL", "items": [{ "description": "Desarrollo", "quantity": 8, "unitPrice": 60 }] }
    ]
  }'
```

**Response (207 Multi-Status):**

```json
{
  "results": [
    { "status": 201, "data": { "id": "inv_001", "clientName": "Acme SL", "status": "draft" } },
    { "status": 201, "data": { "id": "inv_002", "clientName": "TechStart SL", "status": "draft" } }
  ],
  "summary": { "total": 2, "succeeded": 2, "failed": 0 }
}
```

If any item fails validation, the rest are still created. Individual errors are returned in the `results` array:

```json
{
  "results": [
    { "status": 201, "data": { "id": "inv_001", "clientName": "Acme SL" } },
    { "status": 400, "error": { "message": "Missing required field: items" } }
  ],
  "summary": { "total": 2, "succeeded": 1, "failed": 1 }
}
```

**Limits:**

| Concept | Limit |
|---------|-------|
| Items per batch | 50 maximum |
| Request size | 1 MB maximum |

---

## Idempotency

`POST` requests accept an `Idempotency-Key` header to prevent duplicate resource creation during network retries. The key is reserved against the operation **before** it runs, not after, and stays reserved for **24 hours**.

```bash
curl -X POST https://api.frihet.io/v1/invoices \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{
    "clientName": "Acme SL",
    "items": [{ "description": "Consultoria", "quantity": 10, "unitPrice": 95 }]
  }'
```

**Behavior:**

| Scenario | Result |
|----------|--------|
| First request with the key | Resource is created normally |
| Retry of the **same** operation (within 24h) | The **original status and body**, verbatim, with nothing executed |
| The first request is still running, or its outcome could not be recorded | `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` |
| The same key on a **different** operation | `409 IDEMPOTENCY_KEY_REUSED` — the second operation is **not** executed |
| Same key after 24h | Treated as a new request |

The status a replay returns is the **stored** one. A `201` replays as `201`, not `200`. This includes errors: a request that answered `4xx` or `5xx` replays that same status and body rather than re-executing.

**Response header:**

When the API detects a repeated key, it includes the `X-Idempotent-Replayed: true` header so the consumer knows the response is a replay.

```
HTTP/1.1 201 Created
X-Idempotent-Replayed: true
Content-Type: application/json
```

**If a retry gets 409 `IDEMPOTENCY_REQUEST_IN_PROGRESS`:**

When the first request has already returned, that 409 means its outcome **could not be recorded** — the response was too large or unserializable, or the bookkeeping write failed. The API refuses the retry rather than execute the operation a second time: for a fiscal document, a duplicate cannot be undone.

**Reconcile the state; do not simply retry with a new key.** Read the resource back (`GET` the collection, or look for the document the operation would have created) and decide from its actual state. A new key would create a second document — exactly what the first key existed to prevent.

**Operation identity:**

For `POST /v1/invoices/{invoiceId}/credit-note` the request **body** is part of the identity, so the same key with a different body returns `409 IDEMPOTENCY_KEY_REUSED`. For every other endpoint the identity is the method and the path.

**Requirements:**

- The key must be at most **64 characters**
- We recommend using UUID v4
- Only applies to `POST` requests (resource creation)

---

## Search

List endpoints support full-text search via the `q` parameter:

```bash
curl "https://api.frihet.io/v1/invoices?q=acme" \
  -H "X-API-Key: fri_your-key-here"
```

The search applies to the main text fields of the resource (client name, description, notes, etc.). It can be combined with existing filters (`status`, `from`, `to`).

---

## Intelligence endpoints

These endpoints provide aggregated data and business context. They are especially useful for AI agents and external dashboards.

### Business context (`/context`)

```
GET /v1/context
```

Returns a comprehensive business summary, designed to feed AI agents with the context needed to make informed decisions. Includes financial summary, recent activity, alerts, and tax configuration.

```bash
curl https://api.frihet.io/v1/context \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "business": {
    "name": "BRTHLS Studio",
    "taxId": "12345678A",
    "fiscalZone": "canarias",
    "currency": "EUR"
  },
  "summary": {
    "revenue": { "invoiced": 15000, "paid": 12000, "pending": 2000, "overdue": 1000 },
    "expenses": { "total": 4500 },
    "profit": 7500,
    "counts": { "invoices": 25, "clients": 12, "products": 5 }
  },
  "recentActivity": [
    { "type": "invoice.created", "id": "inv_001", "description": "Factura para Acme SL", "timestamp": "2026-03-18T10:00:00Z" },
    { "type": "expense.created", "id": "exp_042", "description": "Adobe Creative Cloud", "timestamp": "2026-03-17T14:30:00Z" }
  ],
  "alerts": [
    { "type": "overdue", "count": 2, "amount": 1000 },
    { "type": "tax_deadline", "model": "303", "dueDate": "2026-04-20" }
  ]
}
```

### Monthly P&L (`/monthly`)

```
GET /v1/monthly?month=YYYY-MM
```

Returns the profit & loss statement for a specific month: invoiced revenue, expenses by category, net profit, and comparison with the previous month.

```bash
curl "https://api.frihet.io/v1/monthly?month=2026-02" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "month": "2026-02",
  "revenue": {
    "invoiced": 8500.00,
    "collected": 6200.00,
    "outstanding": 2300.00
  },
  "expenses": {
    "total": 3100.00,
    "byCategory": {
      "software": 450.00,
      "marketing": 800.00,
      "office": 350.00,
      "professional_services": 1500.00
    }
  },
  "profit": 5400.00,
  "comparison": {
    "revenueChange": 12.5,
    "expenseChange": -5.2,
    "profitChange": 22.1
  }
}
```

### Quarterly tax figures (`/quarterly`)

```
GET /v1/quarterly?quarter=YYYY-Q1
```

**Legacy** endpoint. Returns aggregated quarter figures for Modelo 303 (VAT) and Modelo 130 (income tax). The whole response lives under `data`; `meta` carries `requestId` and `timestamp`.

The `quarter` parameter uses the format `YYYY-Q[1-4]` (defaults to the current quarter).

```bash
curl "https://api.frihet.io/v1/quarterly?quarter=2026-Q1" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": {
    "period": "2026-Q1",
    "months": ["2026-01", "2026-02", "2026-03"],
    "modelo303": {
      "baseImponible": 12000.00,
      "cuotaRepercutida": 2520.00,
      "baseDeducible": 4500.00,
      "cuotaDeducible": 980.00,
      "resultado": 1540.00
    },
    "modelo130": {
      "ingresos": 12000.00,
      "gastos": 4500.00,
      "rendimientoNeto": 7500.00,
      "pagoFraccionado": 1500.00
    },
    "summary": {
      "totalRevenue": 14520.00,
      "totalExpenses": 5480.00,
      "invoiceCount": 8,
      "clientCount": 5
    }
  },
  "meta": {
    "requestId": "req_a1b2c3",
    "timestamp": "2026-04-12T10:00:00Z"
  }
}
```

:::caution Divergence: draft invoices (`draft`)
`/quarterly` only excludes invoices with status `cancelled`, so it **includes draft (`draft`/proforma) invoices** in revenue and in the 303/130 figures. This can **inflate** the amounts versus the real settlement.

For AEAT-aligned figures (which exclude both `cancelled` and `draft`), use [`/v1/fiscal/modelo/{model}`](#fiscal-models-v1fiscalmodelo) — it is the authoritative route. In addition, `/quarterly` does not return `model`, `readonly` or the AEAT note, and its Modelo 303 is thinner (no `baseExenta`, `baseNoSujetaORC` or `outOfScope`); the `/quarterly` Modelo 130 does not include `retencionesSoportadas`.
:::

:::tip
The `/context`, `/monthly`, and `/quarterly` endpoints are designed to be the **ideal entry point for AI agents**. They provide the necessary information in a single call, without needing to query multiple individual endpoints. For code-truthful tax data, prefer `/v1/fiscal/modelo/{model}`.
:::

---

### Fiscal models (`/v1/fiscal/modelo`) {#fiscal-models-v1fiscalmodelo}

```
GET /v1/fiscal/modelo/{model}
```

Returns a computed summary of a Spanish fiscal model from the invoices and expenses already stored. It is the **authoritative** route for tax figures: it excludes both `cancelled` and `draft` (proforma) invoices, aligning with the AEAT criterion.

:::warning Not presented to AEAT
This endpoint is **read-only**. It computes and aggregates already-stored data, but **does not present, sign or transmit anything to AEAT** — that is an irreversible fiscal act kept outside the API on purpose. Every response includes `readonly: true` and a `note` that reminds it.
:::

**Path parameters:**

| Parameter | Values | Description |
|-----------|--------|-------------|
| `model` | `303`, `130`, `390` | Fiscal model to compute |

**Query parameters:**

| Parameter | Applies to | Format | Description |
|-----------|------------|--------|-------------|
| `quarter` | `303`, `130` | `YYYY-Q[1-4]` | Quarter. Defaults to the current quarter |
| `year` | `390` | `YYYY` | Calendar year. Defaults to the current year (sums Q1..Q4) |

:::note Field `model`, not `modeloCode`
The response identifies the model with the `model` field, emitted as a literal string (`"303"`, `"130"` or `"390"`). There is **no** `modeloCode` field nor a `model` alias under any other name in this response.
:::

#### Modelo 303 (quarterly VAT)

```bash
curl "https://api.frihet.io/v1/fiscal/modelo/303?quarter=2026-Q1" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": {
    "model": "303",
    "period": "2026-Q1",
    "months": ["2026-01", "2026-02", "2026-03"],
    "modelo303": {
      "baseImponible": 12000.00,
      "cuotaRepercutida": 2520.00,
      "baseDeducible": 4500.00,
      "cuotaDeducible": 980.00,
      "resultado": 1540.00,
      "baseExenta": 0,
      "baseNoSujetaORC": 0,
      "outOfScope": {
        "igic": { "base": 0, "cuota": 0 },
        "ipsi": { "base": 0, "cuota": 0 }
      }
    },
    "summary": {
      "totalRevenue": 14520.00,
      "totalExpenses": 5480.00,
      "invoiceCount": 8,
      "expenseCount": 14,
      "clientCount": 5
    },
    "readonly": true,
    "note": "READ-ONLY summary. Not presented or submitted to AEAT."
  },
  "meta": {
    "requestId": "req_a1b2c3",
    "timestamp": "2026-04-12T10:00:00Z"
  }
}
```

**`modelo303` fields:**

| Field | Type | Description |
|-------|------|-------------|
| `baseImponible` | number | Base of domestic operations subject to IVA |
| `cuotaRepercutida` | number | Output VAT (charged to clients) |
| `baseDeducible` | number | Base of expenses with deductible IVA |
| `cuotaDeducible` | number | Deductible input VAT |
| `resultado` | number | `cuotaRepercutida − cuotaDeducible`. Positive = to pay; negative = to refund/compensate |
| `baseExenta` | number | Base of exempt operations (reported, zero cuota) |
| `baseNoSujetaORC` | number | Base of intra-community / export / reverse-charge (ISP) operations |
| `outOfScope` | object | Operations in other taxes: `igic` and `ipsi`, each with `base` and `cuota`. **Not** part of the 303 |

#### Modelo 130 (IRPF fractioned payment)

```bash
curl "https://api.frihet.io/v1/fiscal/modelo/130?quarter=2026-Q1" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": {
    "model": "130",
    "period": "2026-Q1",
    "months": ["2026-01", "2026-02", "2026-03"],
    "modelo130": {
      "ingresos": 12000.00,
      "gastos": 4500.00,
      "rendimientoNeto": 7500.00,
      "pagoFraccionado": 1500.00,
      "retencionesSoportadas": 0
    },
    "summary": {
      "totalRevenue": 14520.00,
      "totalExpenses": 5480.00,
      "invoiceCount": 8,
      "expenseCount": 14,
      "clientCount": 5
    },
    "readonly": true,
    "note": "READ-ONLY summary. Not presented or submitted to AEAT."
  },
  "meta": {
    "requestId": "req_a1b2c3",
    "timestamp": "2026-04-12T10:00:00Z"
  }
}
```

**`modelo130` fields:**

| Field | Type | Description |
|-------|------|-------------|
| `ingresos` | number | Period revenue |
| `gastos` | number | Period expenses |
| `rendimientoNeto` | number | Net income (`ingresos − gastos`) |
| `pagoFraccionado` | number | IRPF fractioned payment (20% of net income, simplified direct estimation) |
| `retencionesSoportadas` | number | IRPF withheld by clients on issued invoices |

:::note `retencionesSoportadas` only in `/fiscal/modelo`
The `retencionesSoportadas` field is returned here, in `/v1/fiscal/modelo/130`, but **not** in the legacy `/v1/quarterly` endpoint.
:::

#### Modelo 390 (annual VAT recap)

```bash
curl "https://api.frihet.io/v1/fiscal/modelo/390?year=2026" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": {
    "model": "390",
    "period": "2026",
    "months": ["2026-01", "2026-02", "..."],
    "modelo390": {
      "baseImponible": 48000.00,
      "cuotaRepercutida": 10080.00,
      "baseDeducible": 18000.00,
      "cuotaDeducible": 3920.00,
      "resultadoAnual": 6160.00,
      "baseExenta": 0,
      "baseNoSujetaORC": 0,
      "outOfScope": {
        "igic": { "base": 0, "cuota": 0 },
        "ipsi": { "base": 0, "cuota": 0 }
      }
    },
    "summary": {
      "totalRevenue": 58080.00,
      "totalExpenses": 21920.00,
      "invoiceCount": 32,
      "expenseCount": 56,
      "clientCount": 11
    },
    "readonly": true,
    "note": "READ-ONLY annual VAT recap. Not presented or submitted to AEAT."
  },
  "meta": {
    "requestId": "req_a1b2c3",
    "timestamp": "2026-04-12T10:00:00Z"
  }
}
```

:::note `resultadoAnual`, not `resultado`
Modelo 390 is an annual projection of the same 303 engine (sum of Q1..Q4). The net result is called `resultadoAnual` (not `resultado`).
:::

**Response codes:**

| Code | When it occurs |
|------|----------------|
| `200` | Summary computed correctly |
| `400` | Invalid `quarter` or `year` format. Use `YYYY-Q[1-4]` (303/130) or `YYYY` (390) |
| `404` | Unknown model. Use `303`, `130` or `390` |

Error responses also include the `meta` block with `requestId` and `timestamp`.

---

## Financial dashboard (`/summary`)

```
GET /v1/summary
```

Returns a financial summary of the business: revenue, expenses, profit, and counters.

**Query parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `from` | string | Start date (ISO 8601) |
| `to` | string | End date (ISO 8601) |

```bash
curl "https://api.frihet.io/v1/summary?from=2026-01-01&to=2026-03-31" \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "period": { "from": "2026-01-01", "to": "2026-03-31" },
  "revenue": {
    "invoiced": 15000.00,
    "paid": 12000.00,
    "pending": 2000.00,
    "overdue": 1000.00
  },
  "expenses": { "total": 4500.00 },
  "profit": 7500.00,
  "counts": {
    "invoices": 25,
    "quotes": 8,
    "expenses": 42,
    "clients": 12,
    "products": 5
  },
  "invoicesByStatus": {
    "draft": 3,
    "sent": 5,
    "paid": 15,
    "overdue": 2
  },
  "overdue": { "count": 2, "amount": 1000.00 }
}
```

---

## Error codes

The API uses standard HTTP status codes. Error responses include a JSON object with `error` and, optionally, `message` and `details` fields.

| Code | Meaning | Description |
|------|---------|-------------|
| `400` | Bad Request | A required field is missing, format is incorrect, or a field is not allowed |
| `401` | Unauthorized | API key not provided, invalid, incorrectly formatted, or expired |
| `403` | Forbidden | API key does not have permission to access this resource |
| `404` | Not Found | The requested resource does not exist |
| `405` | Method Not Allowed | HTTP method is not supported for this endpoint |
| `413` | Payload Too Large | Request body exceeds 1 MB |
| `422` | Unprocessable Entity | Data is valid but the server cannot process it (e.g., tax profile not configured) |
| `429` | Too Many Requests | Rate limit of 100 requests per minute exceeded |
| `500` | Internal Server Error | Server-side error |

### Error response structure

**Validation error (400):**

```json
{
  "error": "Validation error",
  "details": [
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": ["clientName"],
      "message": "Required"
    }
  ]
}
```

Validation errors use the Zod format. The `path` field indicates which field contains the error and `message` describes the problem.

**Invalid or expired key (401):**

```json
{
  "error": "Invalid or expired API key"
}
```

**Invalid key format (401):**

```json
{
  "error": "Invalid API key format"
}
```

**Resource not found (404):**

```json
{
  "error": "Resource not found"
}
```

**Invalid status filter (400):**

```json
{
  "error": "Invalid status filter",
  "message": "Valid values: draft, sent, paid, overdue, cancelled"
}
```

**Rate limit exceeded (429):**

```json
{
  "error": "Rate limit exceeded",
  "message": "Maximum 100 requests per minute",
  "retryAfter": 60
}
```

**Internal error (500):**

```json
{
  "error": "Internal server error"
}
```

---

## Pagination

List endpoints return paginated results with the following structure:

```json
{
  "data": [],
  "total": 142,
  "limit": 50,
  "offset": 0
}
```

- `total`: total number of records matching the applied filters
- `limit`: number of records returned in this page (maximum 100)
- `offset`: number of records skipped (maximum 10,000)

To get the next page:

```bash
curl "https://api.frihet.io/v1/invoices?limit=50&offset=50" \
  -H "X-API-Key: fri_your-key-here"
```

Results are ordered by the resource's natural date in descending order (most recent first):
- Invoices and quotes: `issueDate`
- Expenses: `date`
- Clients and products: `createdAt`

---

## Strict validation

The API uses **strict validation** (Zod strict mode). Requests with unknown fields are rejected with a `400` error:

```json
{
  "error": "Validation error",
  "details": [
    {
      "code": "unrecognized_keys",
      "keys": ["campoInventado"],
      "path": [],
      "message": "Unrecognized key(s) in object: 'campoInventado'"
    }
  ]
}
```

This prevents silent errors from typos in field names.

---

## CORS

The API supports CORS for browser requests. Allowed origins are:

- `https://app.frihet.io`
- `https://frihet.io`
- `https://www.frihet.io`

For server-to-server integrations, CORS is not relevant. If you need to access the API from a different domain in the browser, use a proxy in your backend.

---

## Security headers

All responses include security headers:

| Header | Value |
|--------|-------|
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `X-XSS-Protection` | `1; mode=block` |
| `X-Request-Id` | Unique request ID (useful for debugging) |

---

## OAuth for MCP

The `POST /api/oauth/api-key` endpoint allows provisioning an API key automatically through the MCP OAuth flow. The MCP server uses this endpoint internally -- you do not need to call it directly.

The flow:

1. The user authenticates in the Frihet app
2. The MCP client sends the session token to `/api/oauth/api-key`
3. The server returns a new `fri_xxx...` key labeled as "MCP OAuth"
4. The key expires after 365 days

Limit: 5 active keys per user. If the limit is exceeded, the endpoint responds with a `429`.

---

## Best practices

1. **Store your API key securely.** Never include it in frontend code, public repositories, or logs.
2. **Handle rate limiting.** Implement exponential backoff when you receive a `429`.
3. **Use pagination.** Don't request all records at once; iterate with `limit` and `offset`.
4. **Check response codes.** Don't assume every request will succeed.
5. **Rotate keys periodically.** Create a new key, update your integration, then revoke the old one.
6. **Always use HTTPS.** All API requests must go over HTTPS.
7. **Use filters.** The `status`, `from`, and `to` parameters reduce the amount of data transferred.
8. **Use PATCH.** For partial updates, send only the modified fields.
