# 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=,v1=` - `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.