Webhooks
Register webhook endpoints, consume signed deliveries, and verify signatures.
Webhooks send customer events to your HTTPS endpoint as signed JSON POSTs. Use https://app.gtmclarity.ai/api/v1 as the API base URL.
Register an endpoint
Register a customer webhook endpoint with a write key:
curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints \
-X POST \
-H 'Authorization: Bearer gtmc_your_key_here' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/webhooks/gtmclarity",
"description": "Production webhook",
"events": ["conversation.started", "conversion.recorded"]
}'Omit events or send null to receive all events.
The response includes the endpoint and a signing secret:
{
"endpoint": {
"id": 7
},
"secret": "whsec_..."
}The whsec_... secret is shown once. Store it immediately.
Manage endpoints
Use the webhook endpoint API to list, read, update, delete, rotate secrets, and inspect deliveries:
curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints \
-H 'Authorization: Bearer gtmc_your_key_here'curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints/7 \
-H 'Authorization: Bearer gtmc_your_key_here'curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints/7 \
-X PATCH \
-H 'Authorization: Bearer gtmc_your_key_here' \
-H 'Content-Type: application/json' \
-d '{
"description": "Primary production webhook"
}'curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints/7 \
-X DELETE \
-H 'Authorization: Bearer gtmc_your_key_here'curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints/7/rotate-secret \
-X POST \
-H 'Authorization: Bearer gtmc_your_key_here'Delivery logs are cursor paginated. Each delivery includes attempt, status, and nextRetryAt.
curl https://app.gtmclarity.ai/api/v1/customers/42/webhook-endpoints/7/deliveries \
-H 'Authorization: Bearer gtmc_your_key_here'Receive deliveries
GTM Clarity sends a JSON POST:
{
"event": "conversation.started",
"data": {
"customerId": 42,
"orgId": "org_2h9x4kQ",
"conversationId": 118,
"visitorId": "6f9d2c1e-8a3b-4f70-9c55-2d41e0b7a9c3"
},
"ts": 1784448000
}Each request includes:
X-GTMC-Signature: t=<ts>,v1=<hexHmac>X-GTMC-Timestamp
The delivery timeout is 5 seconds. Respond with a 2xx quickly and do work asynchronously.
Events
customerId and orgId are always present in data.
| Event | Additional data keys |
|---|---|
conversation.started | conversationId, visitorId |
conversation.ended | conversationId, status, converted |
identity.resolved | conversationId, resolvedPersonId, company, firstName, lastName, jobTitle, email |
conversion.recorded | conversionEventId, conversationId, conversionType, value |
resolution.recorded | resolutionEventId, conversationId, resolvedPersonId, resolvedCompany |
handoff.requested | escalationId, conversationId, schemaName, urgency, reason |
outcome.marked | conversationId, category — the ladder rung a GTM reviewer marked the chat with, or null when the mark was cleared |
conversation.ended is at-least-once. A bot conversion followed by an operator close re-fires the event with the new terminal status. Consumers must be idempotent on (event, conversationId, status).
Retries
Attempts 1 and 2 happen inline. Later retries happen after approximately 5 minutes, 30 minutes, and 2 hours. After 5 attempts, the delivery is terminal. Inspect terminal and retrying deliveries through the deliveries endpoint.
Verify signatures
Verify the signature against the raw request body string before JSON parsing. The signed payload is ${timestamp}.${rawBody}. Reject requests when |now - t| is greater than your tolerance; 300 seconds is recommended.
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
export function verifyGtmcSignature(
secret: string,
header: string,
rawBody: string,
toleranceSeconds = 300,
): boolean {
const parts = new Map(
header.split(',').map((part) => {
const [key, ...rest] = part.split('=');
return [key, rest.join('=')];
}),
);
const timestamp = Number(parts.get('t'));
const signature = parts.get('v1');
if (!Number.isInteger(timestamp) || !signature) {
return false;
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const actualBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (actualBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(actualBuffer, expectedBuffer);
}