# API Rate Limiting and Webhooks

This document covers two related public API behaviors:

- Authenticated `/api/v1/*` requests are protected by per-key rate limiting.
- Ticket and project lifecycle events can be delivered to tenant-managed outbound webhooks.

## Rate Limiting

### Scope

Rate limiting applies to authenticated public REST API requests that use
`x-api-key`.

It does not apply to:

- health/version endpoints
- internal runner/storage/scheduler/invoicing/client/service endpoints
- mobile auth endpoints

### Default Limits

- Burst capacity: `120` requests
- Sustained refill: `60` requests per minute

That is equivalent to roughly `1` request per second sustained with a burst of
`120`.

Limits are tracked per `(tenant, api_key_id)`.

### Success Headers

Successful authenticated responses include:

- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`

Example:

```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87
Content-Type: application/json
```

### 429 Responses

When a key exceeds its bucket, the API returns `429 Too Many Requests` with:

- `Retry-After`: integer seconds until a token is available
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`: ISO 8601 timestamp

Example:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2026-05-05T14:22:31.000Z
Content-Type: application/json
```

```json
{
  "error": {
    "message": "Too many requests",
    "code": "RATE_LIMITED",
    "details": {
      "retry_after_ms": 12000,
      "remaining": 0
    }
  }
}
```

### Observation Mode Versus Enforcement

Rollout can run in observation mode with `RATE_LIMIT_ENFORCE=false`.

In observation mode:

- the same limit calculation still runs
- the same rate-limit headers still appear
- throttled requests are logged for analysis
- the request is allowed through instead of returning `429`

In enforcement mode (`RATE_LIMIT_ENFORCE=true`), throttled requests return
`429`.

### Client Guidance

- Treat `429` as retryable.
- Use `Retry-After` first if present.
- Back off per key, not globally across unrelated tenants/keys.
- Do not assume a successful request means the next one will also succeed
  immediately if `X-RateLimit-Remaining` is low.

## Ticket Webhooks

### Supported Events

Ticket webhooks support these event types in v1:

- `ticket.created`
- `ticket.updated`
- `ticket.status_changed`
- `ticket.assigned`
- `ticket.closed`
- `ticket.comment.added`

### Delivery Envelope

Every outbound webhook is delivered as JSON:

```json
{
  "event_id": "6e8d9668-e7af-4a71-b734-9e3cb74b06b7",
  "event_type": "ticket.assigned",
  "occurred_at": "2026-05-05T14:10:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "ticket_number": "T-1042",
    "title": "Printer offline",
    "status_id": "33333333-3333-3333-3333-333333333333",
    "status_name": "Open",
    "priority_id": "44444444-4444-4444-4444-444444444444",
    "priority_name": "High",
    "client_id": "55555555-5555-5555-5555-555555555555",
    "client_name": "Acme Manufacturing",
    "contact_name_id": "66666666-6666-6666-6666-666666666666",
    "contact_name": "Jordan Smith",
    "contact_email": "jordan@example.com",
    "assigned_to": "77777777-7777-7777-7777-777777777777",
    "assigned_to_name": "Pat Lee",
    "assigned_team_id": null,
    "board_id": "88888888-8888-8888-8888-888888888888",
    "board_name": "Support",
    "category_id": "99999999-9999-9999-9999-999999999999",
    "subcategory_id": null,
    "is_closed": false,
    "entered_at": "2026-05-05T13:55:00.000Z",
    "updated_at": "2026-05-05T14:10:00.000Z",
    "closed_at": null,
    "due_date": null,
    "tags": ["printer", "onsite"],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
  }
}
```

### Example Payloads

#### `ticket.created`

```json
{
  "event_id": "11111111-aaaa-bbbb-cccc-111111111111",
  "event_type": "ticket.created",
  "occurred_at": "2026-05-05T14:00:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "ticket_number": "T-1042",
    "title": "Printer offline",
    "status_name": "Open",
    "priority_name": "High",
    "client_name": "Acme Manufacturing",
    "tags": [],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
  }
}
```

#### `ticket.updated`

```json
{
  "event_id": "11111111-aaaa-bbbb-cccc-222222222222",
  "event_type": "ticket.updated",
  "occurred_at": "2026-05-05T14:05:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "ticket_number": "T-1042",
    "title": "Printer offline at front desk",
    "status_name": "Open",
    "tags": ["printer"],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222",
    "changes": {
      "title": {
        "previous": "Printer offline",
        "new": "Printer offline at front desk"
      }
    }
  }
}
```

#### `ticket.status_changed`

```json
{
  "event_id": "11111111-aaaa-bbbb-cccc-333333333333",
  "event_type": "ticket.status_changed",
  "occurred_at": "2026-05-05T14:07:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "ticket_number": "T-1042",
    "status_id": "33333333-3333-3333-3333-333333333334",
    "status_name": "In Progress",
    "previous_status_id": "33333333-3333-3333-3333-333333333333",
    "previous_status_name": "Open",
    "tags": [],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
  }
}
```

#### `ticket.assigned`

```json
{
  "event_id": "11111111-aaaa-bbbb-cccc-444444444444",
  "event_type": "ticket.assigned",
  "occurred_at": "2026-05-05T14:10:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "assigned_to": "77777777-7777-7777-7777-777777777777",
    "assigned_to_name": "Pat Lee",
    "status_name": "In Progress",
    "tags": [],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
  }
}
```

#### `ticket.closed`

```json
{
  "event_id": "11111111-aaaa-bbbb-cccc-555555555555",
  "event_type": "ticket.closed",
  "occurred_at": "2026-05-05T16:20:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "status_name": "Closed",
    "is_closed": true,
    "closed_at": "2026-05-05T16:20:00.000Z",
    "tags": [],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
  }
}
```

#### `ticket.comment.added`

```json
{
  "event_id": "11111111-aaaa-bbbb-cccc-666666666666",
  "event_type": "ticket.comment.added",
  "occurred_at": "2026-05-05T14:25:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "ticket_id": "22222222-2222-2222-2222-222222222222",
    "ticket_number": "T-1042",
    "status_name": "In Progress",
    "tags": [],
    "url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222",
    "comment": {
      "text": "Scheduled onsite visit for 3 PM.",
      "author": "Pat Lee",
      "timestamp": "2026-05-05T14:25:00.000Z",
      "is_internal": false
    }
  }
}
```

Comment payloads never include attachments.

## Project Webhooks

### Supported Events

Project webhooks fire for both project-level and project-task lifecycle changes:

- `project.created`
- `project.updated`
- `project.status_changed`
- `project.assigned`
- `project.closed`
- `project.completed` — **deprecated alias** for `project.closed`. Existing subscribers continue to receive it; new integrations should subscribe to `project.closed`.
- `project.task.created`
- `project.task.updated`
- `project.task.status_changed`
- `project.task.assigned`
- `project.task.completed`

### Payload Allowlist and Correlation Keys

Project-level and task-level events share a **single `project` allowlist** in the `payload_fields` configuration; you cannot allowlist task fields independently of project fields.

Two keys are always retained regardless of the allowlist:

- `project_id` — present on every project-family delivery.
- `task_id` — additionally retained on every `project.task.*` delivery.

The supported allowlist values are documented inline in `payload_fields` on the `POST /api/v1/webhooks` request schema (and in the source of truth at `server/src/lib/webhooks/payloadFields.ts`).

### Delivery Envelope

Every outbound webhook is delivered as JSON. Project-level event example:

```json
{
  "event_id": "abc12300-1111-2222-3333-444444444444",
  "event_type": "project.assigned",
  "occurred_at": "2026-05-12T10:00:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "project_name": "Acme Onboarding",
    "wbs_code": "01.02.03",
    "description": "Initial cutover engagement.",
    "status_id": "55555555-5555-5555-5555-555555555555",
    "status_name": "In Progress",
    "is_closed": false,
    "client_id": "66666666-6666-6666-6666-666666666666",
    "client_name": "Acme Manufacturing",
    "contact_name_id": "77777777-7777-7777-7777-777777777777",
    "contact_name": "Jordan Smith",
    "contact_email": "jordan@example.com",
    "assigned_to": "88888888-8888-8888-8888-888888888888",
    "assigned_to_name": "Pat Lee",
    "start_date": "2026-05-01",
    "end_date": "2026-07-31",
    "budgeted_hours": 120,
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
  }
}
```

Project-task envelope (`project.task.*` events) carries a flattened task record alongside the parent project identifiers:

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-555555555555",
  "event_type": "project.task.assigned",
  "occurred_at": "2026-05-12T10:30:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "project_name": "Acme Onboarding",
    "client_id": "66666666-6666-6666-6666-666666666666",
    "client_name": "Acme Manufacturing",
    "task_id": "99999999-9999-9999-9999-999999999999",
    "phase_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
    "phase_name": "Discovery",
    "task_name": "Inventory legacy mailboxes",
    "status_id": "55555555-5555-5555-5555-555555555556",
    "status_name": "In Progress",
    "is_closed": false,
    "assigned_to": "88888888-8888-8888-8888-888888888888",
    "assigned_to_name": "Pat Lee",
    "estimated_hours": 8,
    "actual_hours": 0,
    "due_date": "2026-05-20",
    "priority_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
    "priority_name": "High",
    "wbs_code": "01.02.03.01",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
    "tags": ["onboarding"]
  }
}
```

### Example Payloads

#### `project.created`

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-100000000000",
  "event_type": "project.created",
  "occurred_at": "2026-05-12T09:00:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "project_name": "Acme Onboarding",
    "status_name": "Planning",
    "client_name": "Acme Manufacturing",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
  }
}
```

#### `project.updated`

`changes` is a key-by-key diff of fields that changed in this update; absent fields did not change.

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-200000000000",
  "event_type": "project.updated",
  "occurred_at": "2026-05-12T09:15:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "project_name": "Acme Onboarding — Phase 1",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333",
    "changes": {
      "project_name": {
        "previous": "Acme Onboarding",
        "new": "Acme Onboarding — Phase 1"
      },
      "end_date": {
        "previous": "2026-07-31",
        "new": "2026-08-31"
      }
    }
  }
}
```

#### `project.status_changed`

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-300000000000",
  "event_type": "project.status_changed",
  "occurred_at": "2026-05-12T09:30:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "status_id": "55555555-5555-5555-5555-555555555555",
    "status_name": "In Progress",
    "previous_status_id": "55555555-5555-5555-5555-555555555550",
    "previous_status_name": "Planning",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
  }
}
```

#### `project.assigned`

See the [Delivery Envelope](#delivery-envelope-1) example above.

#### `project.closed`

`project.closed` fires when `is_closed` transitions to `true`.

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-400000000000",
  "event_type": "project.closed",
  "occurred_at": "2026-08-30T17:00:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "status_name": "Completed",
    "is_closed": true,
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
  }
}
```

#### `project.completed`

Same payload as `project.closed`, with `event_type` set to `project.completed`. Delivered for backwards compatibility alongside `project.closed`; new integrations should subscribe to `project.closed`.

#### `project.task.created`

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-500000000000",
  "event_type": "project.task.created",
  "occurred_at": "2026-05-12T10:05:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "project_name": "Acme Onboarding",
    "task_id": "99999999-9999-9999-9999-999999999999",
    "task_name": "Inventory legacy mailboxes",
    "phase_name": "Discovery",
    "status_name": "Not Started",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
    "tags": []
  }
}
```

#### `project.task.updated`

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-600000000000",
  "event_type": "project.task.updated",
  "occurred_at": "2026-05-12T10:10:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "task_id": "99999999-9999-9999-9999-999999999999",
    "due_date": "2026-05-22",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
    "tags": ["onboarding"],
    "changes": {
      "due_date": { "previous": "2026-05-20", "new": "2026-05-22" },
      "tags": { "previous": [], "new": ["onboarding"] }
    }
  }
}
```

When `changes.tags` is present it carries the **authoritative post-change tag set**; clients should prefer it over the cached `tags` snapshot.

#### `project.task.status_changed`

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-700000000000",
  "event_type": "project.task.status_changed",
  "occurred_at": "2026-05-12T10:20:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "task_id": "99999999-9999-9999-9999-999999999999",
    "status_id": "55555555-5555-5555-5555-555555555556",
    "status_name": "In Progress",
    "previous_status_id": "55555555-5555-5555-5555-555555555551",
    "previous_status_name": "Not Started",
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
    "tags": ["onboarding"]
  }
}
```

#### `project.task.assigned`

See the project-task [Delivery Envelope](#delivery-envelope-1) example above.

#### `project.task.completed`

`project.task.completed` fires when a task transitions to a closed status.

```json
{
  "event_id": "abc12300-aaaa-bbbb-cccc-800000000000",
  "event_type": "project.task.completed",
  "occurred_at": "2026-05-19T16:45:00.000Z",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "data": {
    "project_id": "33333333-3333-3333-3333-333333333333",
    "task_id": "99999999-9999-9999-9999-999999999999",
    "status_name": "Done",
    "is_closed": true,
    "url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
    "tags": ["onboarding"]
  }
}
```

## Shared Webhook Mechanics

Signature, verification, delivery semantics, retry, and outbound rate limits apply to all webhook events regardless of family (ticket, project, …).

### Signature Headers

Each delivery includes these headers:

- `X-Alga-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>`
- `X-Alga-Webhook-Id`
- `X-Alga-Event-Id`
- `X-Alga-Event-Type`
- `X-Alga-Delivery-Id`
- `X-Alga-Delivery-Attempt`

The signature is computed over:

```text
${timestamp}.${raw_request_body}
```

### Verification Recipe

#### Node.js

```js
import crypto from 'node:crypto';

function verifySignature(secret, rawBody, signatureHeader) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((item) => item.split('='))
  );

  const payload = `${parts.t}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  return expected === parts.v1;
}
```

#### Python

```python
import hmac
import hashlib

def verify_signature(secret: str, raw_body: str, signature_header: str) -> bool:
    parts = dict(item.split("=", 1) for item in signature_header.split(","))
    payload = f"{parts['t']}.{raw_body}".encode("utf-8")
    expected = hmac.new(
        secret.encode("utf-8"),
        payload,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

Reject payloads whose timestamp is too old for your replay window. A
five-minute window is recommended.

### Delivery Semantics

- Delivery is at least once.
- `event_id` is the idempotency key.
- Ordering is not guaranteed across different webhook subscriptions.
- Ordering is not guaranteed across different event types for the same ticket.
- Consumers should make handlers idempotent and safe to replay.

### Retry Behavior

Failed non-test deliveries are retried with this schedule:

| Attempt after failure | Delay |
| --- | --- |
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |

After the fifth failed attempt, the delivery is abandoned.

### Per-Webhook Outbound Rate Limit

Webhook delivery also has an outbound cap per webhook. The default is
`100 deliveries per minute` per `(tenant, webhook_id)`.

Test deliveries sent through `POST /api/v1/webhooks/{id}/test` do not consume
that outbound bucket.
