PSA/docs/api/api-rate-limiting-and-webhooks.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

662 lines
19 KiB
Markdown

# 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.