API reference
Webhooks
The API answers when you ask. A webhook tells you when something happened: a reply, an opt-out, a booked meeting. Each delivery is a signed POST to an HTTPS endpoint you control.
Connect an endpoint
In the app, open Settings → Integrations, choose Webhook and paste your HTTPS URL. The endpoint is verified with a signed connector.test delivery before it is saved, so it has to be reachable and answer 2xx. The signing secret is generated at that moment and shown once. Store it; it cannot be read again, only replaced by reconnecting.
Then pick which events it receives under Settings → API → Webhooks. An endpoint with nothing picked receives every event. Webhooks belong to a workspace: an agency's client workspace delivers to the endpoints connected inside that client, and to no others.
Events
| Type | Sent when |
|---|---|
reply.received | A contact replied, on any channel. detail carries an excerpt of the reply. |
reply.opt_out | A contact asked to be removed. They are suppressed before this is sent. |
meeting.booked | A conversation reached a booked meeting, or one was announced through the API. |
contact.created | A new lead entered the workspace. |
sequence.step | A campaign reached a Push to CRM or Send Webhook step for a contact. |
connector.test | Sent once when the endpoint is connected or re-tested. Answer 2xx; there is no contact. |
Payload
One shape for every event. Webhook payloads predate the REST API and are camelCase; they will stay that way, because endpoints already parse them.
{
"type": "reply.received",
"kind": "reply.received",
"occurredAt": "2026-09-17T08:22:40.000Z",
"workspace": "Northbeam",
"contact": {
"email": "dana@northbeam.example",
"firstName": "Dana",
"lastName": "Whitfield",
"title": "VP Demand Generation",
"company": "Northbeam Logistics",
"companyDomain": "northbeam.example",
"linkedinUrl": "https://www.linkedin.com/in/example",
"country": "US"
},
"summary": "Dana Whitfield replied",
"detail": "Could you do Tuesday?",
"url": "https://app.trygtm.com/inbox"
}type and kind are the same value. detail is an excerpt capped at 600 characters, or null. url is a deep link into the app. To act on the contact through the API, look them up with GET /contacts?search= and their email.
Verify the signature
Every delivery carries two headers:
X-TryGTM-Timestamp— Unix seconds when the delivery was signed.X-TryGTM-Signature— lowercase hex HMAC-SHA256 of{timestamp}.{raw body}, keyed with your signing secret.
Compute the same HMAC over the raw request body, compare in constant time, and reject a timestamp more than five minutes old so a captured delivery cannot be replayed. Verify against the bytes you received, not a re-serialised object: key order and whitespace change the digest.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request): Promise<Response> {
const body = await request.text();
const timestamp = request.headers.get("x-trygtm-timestamp") ?? "";
const signature = request.headers.get("x-trygtm-signature") ?? "";
const expected = createHmac("sha256", process.env.TRYGTM_WEBHOOK_SECRET!)
.update(`${timestamp}.${body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const valid =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!fresh || !valid) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(body);
// Queue the work and answer quickly.
return new Response(null, { status: 204 });
}Delivery and retries
Answer any 2xx to acknowledge. Anything else, or no answer, is a failure and is retried with exponential backoff: 5 minutes, then doubling, capped at 6 hours, for 8 attempts over about 11 hours. After the last attempt the delivery is dropped.
- A retry carries the original payload with a fresh timestamp and signature, so verify each delivery on its own headers.
- Deliveries can arrive more than once and out of order. Make your handler idempotent on the contact and
occurredAt. - A failing endpoint is marked degraded in Settings → Integrations with the last error, and returns to connected on the next success.
GET /integrationsreports the same status.
No endpoint to manage webhooks
Webhook endpoints are connected in the app rather than over the API, because the signing secret is shown exactly once and that moment needs a person. Everything else about them is readable: GET /integrations lists each endpoint and its health.