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
| Event | Fires when |
|---|---|
lead.created | A visitor submitted the in-chat contact form. |
form.submitted | A visitor submitted one of your custom forms. |
ticket.created | An agent raised a support ticket. |
ticket.assigned | A ticket was assigned to a teammate. |
ticket.status_changed | A ticket moved to a different status. |
ticket.replied | A 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
- Go to Settings → Webhooks.
- Choose an event.
- Enter an HTTPS target URL.
- Optionally scope it to one agent. Leave it org-wide to receive the event from every agent.
- 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
| Header | Contents |
|---|---|
x-tia-event | The event name. |
x-tia-delivery | Unique delivery id — use it for idempotency. |
x-tia-signature | t=<unix seconds>,v1=<hex hmac> — see below. |
User-Agent | TIA-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
| Behaviour | Value |
|---|---|
| Request timeout | 10 seconds |
| Attempts | 5 |
| Backoff | 1 min → 5 min → 30 min → 2 hours, then terminal |
| Auto-disable | 15 consecutive failures disables the subscription |
410 Gone | Deletes 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
2xximmediately 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
410when you retire an endpoint, so the subscription cleans itself up. - Don't assume ordering. Deliveries are independent; a
ticket.status_changedcan arrive before theticket.createdretry 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
| Limit | Value |
|---|---|
| Subscriptions per organization | 100 |
| Signature tolerance | 300 seconds |
| Attempts per delivery | 5 |
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
- Zapier — the same events, without writing a receiver.
- Integration API — manage subscriptions programmatically.
- Helpdesk & Tickets — the source of four of the six events.