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

19 KiB

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/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/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
{
  "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:

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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.completeddeprecated 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:

{
  "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:

{
  "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

{
  "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.

{
  "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

{
  "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 example above.

project.closed

project.closed fires when is_closed transitions to true.

{
  "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

{
  "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

{
  "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

{
  "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 example above.

project.task.completed

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

{
  "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:

${timestamp}.${raw_request_body}

Verification Recipe

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

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.