GETTING STARTED

Webhooks

Webhooks let your systems react to AlgaPSA events without polling. AlgaPSA POSTs a signed JSON envelope to a URL you configure whenever a subscribed event happens. This page covers the supported events, the delivery envelope, the X-Alga-Signature verification recipe, and the retry and idempotency contract.

Ticket events

The following ticket events are available in v1. Subscribe to one or more by listing them in a webhook's event_types array when you call POST /api/v1/webhooks.

ticket.createdevent
A new ticket was created.
ticket.updatedevent
Fields on an existing ticket changed.
ticket.status_changedevent
A ticket moved between statuses. The payload includes the previous and current status.
ticket.assignedevent
A ticket was assigned (or reassigned) to a user or team.
ticket.closedevent
A ticket was closed.
ticket.comment.addedevent
A new comment was added to a ticket.

The full vocabulary — including the project, client, contact, time-entry, invoice, asset, system, and workflow event names — is available from GET /api/v1/webhooks/events. Only the ticket events listed above are wired up to a real delivery pipeline at this time.

Delivery envelope

Every delivery is a JSON object with a stable top-level shape. Subscribe to event_id as the idempotency key — a single event may be delivered more than once.

POST https://your-app.example.com/webhooks/alga
{
  "event_id": "9f3b7d2a-2e7e-4c0f-8a4d-2c0c1d3a8b21",
  "event_type": "ticket.assigned",
  "occurred_at": "2026-05-07T15:24:11.482Z",
  "tenant_id": "f3b62b7c-15b3-4f8a-9b6d-2a0e1a2c4f55",
  "data": {
    "ticket_id": "1a2b3c4d-...",
    "ticket_number": "TKT-1042",
    "title": "Outlook crashes on launch",
    "status_id": "...",
    "status_name": "In Progress",
    "previous_status_id": "...",
    "previous_status_name": "Open",
    "priority_id": "...",
    "priority_name": "High",
    "client_id": "...",
    "client_name": "Acme Corp",
    "contact_name_id": "...",
    "contact_name": "Jordan Lin",
    "contact_email": "jordan@acme.example",
    "assigned_to": "...",
    "assigned_to_name": "Priya Patel",
    "assigned_team_id": null,
    "board_id": "...",
    "board_name": "Helpdesk",
    "category_id": "...",
    "subcategory_id": null,
    "is_closed": false,
    "entered_at": "2026-05-07T14:50:11.000Z",
    "updated_at": "2026-05-07T15:24:11.000Z",
    "closed_at": null,
    "due_date": "2026-05-08T17:00:00.000Z",
    "tags": ["email", "outlook"],
    "url": "https://algapsa.com/msp/tickets/TKT-1042"
  }
}

The data field is a curated subset of the ticket record — stable across releases. ticket.updated deliveries also include a changes diff alongside data. ticket.comment.added deliveries include the comment text, author, timestamp, and an internal/external flag, but do not embed attachments — fetch attachments by their URL.

Request headers

Every delivery includes the following headers:

X-Alga-Signaturestring
HMAC-SHA256 signature in the form t=<unix>,v1=<hex>. See verification recipe below.
X-Alga-Webhook-Iduuid
The webhook configuration that produced the delivery.
X-Alga-Event-Iduuid
Stable identifier for the event. Use this as your idempotency key.
X-Alga-Event-Typestring
The event name — e.g. ticket.assigned.
X-Alga-Delivery-Iduuid
Identifier for this individual delivery attempt record. Different from the event id when a delivery is retried.
X-Alga-Delivery-Attemptint
1 on the first attempt, incremented for each retry.

Signing secrets

AlgaPSA generates a 32-byte base64url signing secret when you create a webhook. The plaintext secret is returned once, in the create response. Store it in your secret manager immediately — there is no reveal endpoint.

If you lose a secret, rotate it with POST /api/v1/webhooks/{id}/secret/rotate. Rotation generates a new secret, returns the new plaintext value once, and invalidates the previous secret immediately.

Verifying signatures

Compute the signature as HMAC_SHA256(secret, t + "." + raw_body) and compare against the hex value after v1=. Reject any request where the timestamp drifts more than five minutes from your server clock.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyAlgaSignature(
  rawBody: string,
  header: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const [k, v] = p.split("=");
      return [k, v];
    }),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  const drift = Math.abs(Math.floor(Date.now() / 1000) - t);
  if (drift > toleranceSeconds) return false;

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

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
verify.py
import hmac, hashlib, time

def verify_alga_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(int(time.time()) - t) > tolerance:
        return False

    payload = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
verify.go
package alga

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strconv"
    "strings"
    "time"
)

func VerifyAlgaSignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
    var t int64
    var v1 string
    for _, part := range strings.Split(header, ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            n, err := strconv.ParseInt(kv[1], 10, 64)
            if err != nil {
                return false
            }
            t = n
        case "v1":
            v1 = kv[1]
        }
    }
    if t == 0 || v1 == "" {
        return false
    }

    if d := time.Since(time.Unix(t, 0)); d < -tolerance || d > tolerance {
        return false
    }

    h := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(h, "%d.", t)
    h.Write(rawBody)
    expected := hex.EncodeToString(h.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(v1))
}
verify.php
<?php
function verify_alga_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, null);
        if ($k !== null) $parts[$k] = $v;
    }
    if (empty($parts['t']) || empty($parts['v1'])) return false;

    $t = (int) $parts['t'];
    if (abs(time() - $t) > $tolerance) return false;

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

Delivery semantics

AlgaPSA aims for at-least-once delivery. A single event may arrive at your endpoint more than once if a previous attempt timed out or returned a non-2xx status. Treat X-Alga-Event-Id as the idempotency key in your handler — record it on first receipt and skip subsequent deliveries with the same id.

Ordering is not guaranteed. Two events for the same ticket may arrive out of order, especially when one is retried. Use occurred_at in the envelope to sequence events in your own system if order matters.

Retry policy

AlgaPSA considers a delivery successful when your endpoint returns any 2xx status within ten seconds. Anything else — non-2xx, timeout, connection error — triggers a retry. Default retry schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, for up to five attempts. After the final attempt the delivery is marked abandoned.

If a webhook produces only failed deliveries for 24 hours, AlgaPSA auto-disables it and emails the user who created it. Re-enable it from the webhook settings UI or with PUT /api/v1/webhooks/{id} after fixing the receiving endpoint.

Test deliveries

Use POST /api/v1/webhooks/{id}/test to send a signed test envelope to a webhook's configured URL. The delivery is recorded in the webhook's history with is_test=true, uses the live signing secret, and skips the per-webhook rate limit so you can run it repeatedly during onboarding.

Inspecting deliveries

Each delivery attempt is recorded in webhook_deliveries. Read the history with GET /api/v1/webhooks/{id}/deliveries and retry an individual attempt with POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/retry. Records older than 30 days are pruned automatically.

Outbound rate limit

Each webhook has its own outbound rate limit (default 100 deliveries per minute, configurable per webhook). It governs how fast AlgaPSA dispatches deliveries to your URL, not how fast your integration calls the API — for that, see Rate limits.

SSRF protection

Webhook URLs that resolve to private network ranges — loopback, RFC1918, link-local, carrier-grade NAT — are rejected at delivery time and at the test endpoint. Use a public hostname or, for staging, contact support to grant a per-tenant exemption.