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.
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.
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.
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.
{
"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.
Every delivery includes the following headers:
t=<unix>,v1=<hex>. See verification recipe below.ticket.assigned.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.
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.
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);
}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)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))
}<?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']);
}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.
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.
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.
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.
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.
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.