# Webhooks

URL: https://www.agencytitan.com/docs/tag/webhooks

AgencyTitan can POST tenant events to your HTTPS endpoint when enabled events occur. Configure outbound webhooks from **Settings -> System -> Webhooks** with one global endpoint, per-event enable toggles, optional per-event override URLs, a signing secret, a ping test, and a delivery log with manual redelivery.

Every delivery uses this JSON envelope. The top-level `id` is unique per event and should be used for idempotency on your side:

```json
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "event_type": "client.created",
  "created_at": "2026-07-08T07:26:37.000Z",
  "tenant_id": "123e4567-e89b-12d3-a456-426614174001",
  "data": {
    "...": "event-specific fields"
  }
}
```

Each delivery is an HTTPS `POST` with these headers:

- `Content-Type: application/json`
- `User-Agent: AgencyTitan-Webhooks/1.0`
- `X-AT-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>`
- `X-AT-Event: <event type>`
- `X-AT-Delivery-Id: <delivery row id>`
- `X-AT-Event-Id: <event envelope id>`

The signature is computed as `hex(HMAC_SHA256(secret, `${t}.${rawBody}`))`, where the secret is the tenant webhook signing secret (`whsec_...`). Verify the **raw request body** before you parse JSON, reject stale timestamps (recommended: 5 minutes), and use a constant-time comparison.

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyAgencyTitanWebhook({ rawBody, signatureHeader, secret, toleranceSeconds = 300 }) {
  const pairs = Object.fromEntries(
    signatureHeader.split(',').map((part) => {
      const [key, value] = part.split('=');
      return [key, value];
    }),
  );

  const timestamp = Number(pairs.t);
  const received = pairs.v1;
  if (!Number.isFinite(timestamp) || !received) return false;

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

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

  if (received.length !== expected.length) return false;

  return timingSafeEqual(
    Buffer.from(received, 'utf8'),
    Buffer.from(expected, 'utf8'),
  );
}
```

```text
Generic verification flow:
1. Parse `X-AT-Signature` into `t` and `v1`.
2. Reject the request if `t` is older than your replay window.
3. Compute HMAC-SHA256 over `${t}.${rawBody}` with your signing secret.
4. Constant-time compare the computed digest to `v1`.
5. Only then trust the JSON payload.
```

Any `2xx` response counts as delivered. Respond within **30 seconds**; timeouts, network failures, and any non-`2xx` response are retried up to **8 total attempts** with backoff after each failed delivery: **30 seconds**, **2 minutes**, **10 minutes**, **30 minutes**, **2 hours**, **6 hours**, and **12 hours**. After **50 consecutive failures**, AgencyTitan auto-disables the endpoint until it is saved as active again.

### Available events

| Category | Event | Description |
|----------|-------|-------------|
| client | `client_note.created` | Triggers when a new note is added to a client |
| client | `client_note.updated` | Triggers when a client note is modified |
| client | `client.bank_authorization_required` | Triggers when a saved bank (ACH) payment method needs client authorization before it can be charged (e.g. imported from a PSP without a usable mandate) |
| client | `client.created` | Triggers when a new client is created. Fires only for initial client creation; prefer client.updated for later tracked field changes. |
| client | `client.deleted` | Triggers when a client is deleted |
| client | `client.merged` | Triggers when a client is merged into a surviving client |
| client | `client.updated` | Triggers when a client is updated. Fires for tracked field changes after creation and carries _changed_fields; prefer client.created for initial creation. |
| client_program | `client_program.added` | Triggers when a client is subscribed to a program |
| client_program | `client_program.billing_changed` | Triggers when billing frequency, amount, or pricing override changes |
| client_program | `client_program.cancelled` | Triggers when a client manually cancels their program subscription. Cancellation is a churn event: recovery / win-back / retention follow-ups belong here as well as on client_program.suspended. |
| client_program | `client_program.completed` | Triggers when a program reaches its end date and completes |
| client_program | `client_program.paused` | Triggers when an active client program is paused by user |
| client_program | `client_program.resumed` | Triggers when a paused or suspended client program is resumed |
| client_program | `client_program.started` | Triggers when a program actually starts (status = active AND start date arrived) |
| client_program | `client_program.suspended` | Triggers when a client program is suspended due to payment failure |
| custom_object | `custom_object_record.created` | Triggers when a new custom object record is created |
| custom_object | `custom_object_record.deleted` | Triggers when a custom object record is deleted |
| custom_object | `custom_object_record.updated` | Triggers when a custom object record is updated |
| escalation | `escalation.comment_added` | Triggers when a comment is added to an escalation |
| escalation | `escalation.created` | Triggers when a new escalation is created. Fires only for initial escalation creation; prefer escalation.updated for later detail changes. |
| escalation | `escalation.reassigned` | Triggers when an escalation is assigned to a different user |
| escalation | `escalation.status_changed` | Triggers when an escalation transitions between statuses |
| escalation | `escalation.updated` | Triggers when escalation details change. Fires for detail changes after creation and carries _changed_fields; for a specific-field-changed intent, filter _changed_fields for that field, and prefer escalation.created for initial creation. |
| forms | `form.submission_ready` | Fires when a submission is finalized and ready to act on. Prefer form.submission for pre-review handling immediately on arrival. |
| forms | `form.submission_rejected` | Fires after a reviewer rejects a held submission. |
| manual | `manual.button` | Triggers when a button or action link is clicked by a user |
| sop | `sop.archived` | Triggers when an SOP is archived |
| sop | `sop.feedback_received` | Triggers when feedback is submitted on an SOP (ratings, comments) or when an SOP is flagged as outdated |
| sop | `sop.published` | Triggers when an SOP goes live (new or draft→published) |
| sop | `sop.updated` | Triggers when an SOP is updated (content, steps, or metadata) |
| task | `process.instance_completed` | Triggers when a task is completed (also fires task updated) |
| task | `process.instance_created` | Triggers when a new task is created. Fires only for initial task creation; prefer process.instance_updated for changes to an existing task. |
| task | `process.instance_deleted` | Triggers when a task is deleted |
| task | `process.instance_updated` | Triggers when any task field changes (status, assignee, stage, custom fields, etc.). Fires for task changes after creation and carries _changed_fields; prefer process.instance_created for initial task creation. |
| task | `process.merged` | Triggers when a task is merged into another |
| task | `process.stage_changed` | Triggers when a task moves to a new stage (also fires task updated) |
| ticket | `ticket.all_linked_processes_completed` | Triggers when a linked task completes and all remaining non-deleted, non-reference-only task links on the ticket point to completed tasks |
| ticket | `ticket.assigned` | Triggers when one or more agents are assigned to a ticket |
| ticket | `ticket.created` | Triggers when a new ticket is opened (inbound, portal, manual, or workflow). Fires on every ticket creation regardless of source; for message-driven intake (the first inbound message opening a ticket), prefer ticket.message_received with the is_new_ticket boolean filter. |
| ticket | `ticket.draft_review_approved` | Triggers when a pending draft review is approved and the message proceeds to send |
| ticket | `ticket.draft_review_rejected` | Triggers when a pending draft review is rejected or revisions are requested |
| ticket | `ticket.draft_review_requested` | Triggers when an outbound ticket draft is queued for manager review |
| ticket | `ticket.linked_process_attached` | Triggers when an existing task is linked to a ticket |
| ticket | `ticket.linked_process_created` | Triggers when a task is created and linked from a ticket |
| ticket | `ticket.merged` | Triggers when a ticket is merged into another |
| ticket | `ticket.message_received` | Triggers on an inbound customer message on a ticket. In a task (process) automation it runs once per live linked task of that process type. Fires for every inbound customer message, including the first message that opens a ticket; use the is_new_ticket boolean filter to limit it to first-message intake, and prefer ticket.created for creations from any source. |
| ticket | `ticket.message_sent` | Triggers on an outbound agent message on a ticket (including auto-send) |
| ticket | `ticket.moved` | Triggers when a ticket is moved to a different inbox |
| ticket | `ticket.priority_changed` | Triggers when a ticket priority is updated |
| ticket | `ticket.sla_at_risk` | Triggers when a ticket enters the at-risk SLA window (first response or resolution) |
| ticket | `ticket.sla_breached` | Triggers when a ticket breaches an SLA deadline (first response or resolution) |
| ticket | `ticket.spam_marked` | Triggers when a ticket (or the sender behind it) is marked as spam |
| ticket | `ticket.status_changed` | Triggers when a ticket transitions between statuses |
| ticket | `ticket.unassigned` | Triggers when agents are removed from a ticket (all or partial) |
| ticket | `ticket.updated` | Triggers when ticket fields change (subject, tags, inbox, client, etc.). Does not fire for pure status, priority, or assignment changes - use the dedicated triggers for those. Fires only for changes after creation and carries _changed_fields; prefer ticket.created for initial creation or the dedicated change trigger for status, priority, or assignment changes. |
| training | `training.assessment_failed` | Triggers when a user fails a training assessment |
| training | `training.assessment_passed` | Triggers when a user passes a training assessment |
| training | `training.assessment_submitted` | Triggers when a user submits a training assessment |
| training | `training.course_completed` | Triggers when a user completes a training course |
| training | `training.course_failed` | Triggers when a user fails a training course |
| user | `user.invited` | Triggers when a new user is invited. Fires at invitation time, before the user has accepted or completed setup; prefer user.setup_completed for automations that act on a ready account (training assignments, workspace provisioning). |
| user | `user.setup_completed` | Triggers when a user finishes the in-app user setup flow for an agency. The user's account is ready for in-app work from this point; prefer user.invited for pre-acceptance messaging. |

## Operations

_This tag has no REST operations (guide / concept page)._

## Useful links

- Interactive page: https://www.agencytitan.com/docs/tag/webhooks
- API reference home: https://www.agencytitan.com/docs/
- This page as Markdown: https://www.agencytitan.com/docs/tag/webhooks.md
- Full API Markdown: https://api.agencytitan.com/v1/llms.txt
