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

TypeSent when
reply.receivedA contact replied, on any channel. detail carries an excerpt of the reply.
reply.opt_outA contact asked to be removed. They are suppressed before this is sent.
meeting.bookedA conversation reached a booked meeting, or one was announced through the API.
contact.createdA new lead entered the workspace.
sequence.stepA campaign reached a Push to CRM or Send Webhook step for a contact.
connector.testSent 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 /integrations reports 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.

Was this page helpful?