Sign In

Webhooks & Events

Webhooks push events out of TIA as they happen: a lead arrives, a ticket changes hands, a teammate replies. Point one at your own endpoint, at Zapier, at Make, at n8n — they all run over the same delivery path.

Webhook setup

Connect TIA events to another system

Choose the event and agent scope

Select the TIA change another system needs, then receive it from all agents or one Concierge.

Add the HTTPS endpoint

Enter the receiving URL, add the webhook, and retain the signing secret used to verify deliveries.

Manage subscriptions at Settings → Webhooks, or through the Integration API.

Events

EventFires when
lead.createdA visitor submitted the in-chat contact form.
form.submittedA visitor submitted one of your custom forms.
ticket.createdAn agent raised a support ticket.
ticket.assignedA ticket was assigned to a teammate.
ticket.status_changedA ticket moved to a different status.
ticket.repliedA teammate replied to the requester.

Events are emitted from the write paths themselves — the same function that creates a lead emits lead.created — so there is exactly one emit site per event and no way to write a record without firing it.

Emission never fails a visitor's action. If every one of your endpoints is down, the lead still saves. A subscriber's outage is recorded in the delivery log, not surfaced to the person filling in your form.

Creating a subscription

  1. Go to Settings → Webhooks.
  2. Choose an event.
  3. Enter an HTTPS target URL.
  4. Optionally scope it to one agent. Leave it org-wide to receive the event from every agent.
  5. Copy the signing secret shown on creation. You'll need it to verify deliveries.

A subscription with no agent is org-wide. A subscription naming an agent fires only for that agent.

URL requirements

  • HTTPS only. Payloads carry lead and ticket PII; plaintext HTTP would put that on the wire. There's no localhost exception — a tunnel gives you HTTPS anyway.
  • No credentials in the URL (https://user:pass@… is rejected).
  • Public hosts only. The target is resolved and checked against an SSRF blocklist — and checked again before every single delivery, not just at subscribe time. A hostname that resolved publicly yesterday can be re-pointed at an internal address today; checking once would leave a standing primitive against internal infrastructure.
  • Redirects are refused. A redirect could bounce a request past the host check.

The payload

Every delivery is a POST with this envelope:

{
  "id": "evt_x8f2ck39dl2m4n5p",
  "event": "ticket.created",
  "organizationId": "org_...",
  "agentId": "agent_...",
  "occurredAt": "2026-08-11T09:14:22.117Z",
  "data": {
    "ticketNumber": "TKT-1042",
    "subject": "Damaged on arrival",
    "status": "new",
    "priority": "high",
    "requesterEmail": "jo@example.com"
  }
}

agentId is null for org-wide events. data is shaped by the event.

Headers

HeaderContents
x-tia-eventThe event name.
x-tia-deliveryUnique delivery id — use it for idempotency.
x-tia-signaturet=<unix seconds>,v1=<hex hmac> — see below.
User-AgentTIA-Webhooks/1

Verifying the signature

Signatures are HMAC-SHA256 over `${timestamp}.${rawBody}`, in Stripe's format.

The timestamp is inside the signed material, not merely sent alongside it. That's what makes replay rejection possible: signing the body alone would let anyone who captured one request resend it forever with a valid signature.

Verify against the raw request body, before any JSON parsing. Re-serializing changes the bytes and the signature will not match.

import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

export function verifyTiaWebhook(
  rawBody: string,
  header: string,
  secret: string
): boolean {
  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('=') as [string, string])
  )
  const timestamp = Number(parts.t)
  if (!Number.isFinite(timestamp) || !parts.v1) return false

  // Reject replays outside the tolerance window.
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false

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

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.v1, 'hex')
  // Length check first: timingSafeEqual throws on mismatched lengths.
  if (a.length !== b.length || a.length === 0) return false
  return timingSafeEqual(a, b)
}

Zapier subscriptions are not signed. Zapier's hook URL is itself the unguessable credential, and there's nowhere in a Zap to put a signing key.

Retries and failure

BehaviourValue
Request timeout10 seconds
Attempts5
Backoff1 min → 5 min → 30 min → 2 hours, then terminal
Auto-disable15 consecutive failures disables the subscription
410 GoneDeletes the subscription immediately

410 Gone is an instruction, not an error. It's Zapier's documented way of saying "this Zap is off, stop sending" — so it removes the subscription rather than counting as a failure. If your own endpoint is retired for good, returning 410 is the clean way to say so.

A disabled subscription can be re-enabled from Settings → Webhooks once you've fixed the endpoint. Its failure count resets.

The backoff schedule is tuned so a receiver having a bad ten minutes recovers with no intervention, while a permanently dead endpoint stops consuming capacity within a few hours.

Writing a good receiver

  • Respond fast, then work. Acknowledge with 2xx immediately and do the real work asynchronously. Anything past 10 seconds is a failed attempt.
  • Be idempotent on x-tia-delivery. Retries are expected, and at-least-once delivery is the contract.
  • Verify before you trust. Check the signature and the timestamp window on every request.
  • Return 410 when you retire an endpoint, so the subscription cleans itself up.
  • Don't assume ordering. Deliveries are independent; a ticket.status_changed can arrive before the ticket.created retry that preceded it.

The delivery log

Each subscription has a delivery log in Settings → Webhooks: attempt count, response status, last error, and next scheduled attempt. A broken endpoint is diagnosable there without anyone opening a support ticket.

Retries between attempts are driven by a sweeper. If you self-host, point a scheduler at POST /api/cron/webhook-sweep every few minutes with the CRON_SECRET bearer. That path fails closed with 503 when the secret is unset — an unconfigured secret must never mean "no auth required" on a public route.

Limits

LimitValue
Subscriptions per organization100
Signature tolerance300 seconds
Attempts per delivery5

Creating and deleting subscriptions requires an API key; the REST admin API is available from Growth. Managing them in the dashboard is not plan-gated.

Next steps