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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
662 lines
19 KiB
Markdown
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.
|