Skip to content

API keys and webhooks

Both pages are Admin-only.

Settings → API keys (/settings/api-keys) lists your keys with their name, prefix, creation date, last-used date, status, and a Revoke button.

  1. Click New API key, name it for the integration that will use it, and click Generate key.
  2. Copy the key. It starts with ngk_live_ and is shown exactly once — if you lose it, revoke it and generate another.
  3. Use it as a bearer token: Authorization: Bearer ngk_live_….

The endpoints, request bodies, and record schemas are in the API reference at /api-docs (also linked from the page as View API docs). A usage section charts requests per key over the last 24 hours.

A key acts with the authority of the Admin who created it, on the records that person can see. It stops working while its creator is suspended or removed, and resumes on reactivation — it is not revoked by suspension. Requests are rate-limited per key (60 a minute by default) and per workspace (600 a minute by default); a limited request returns 429 with a Retry-After header.

Settings → Webhooks (/settings/webhooks) sends an HTTP POST to a URL of yours whenever a record you care about is created, updated, or deleted.

  1. Click New webhook.
  2. Give it a name and an HTTPS URL. Plain http is refused, and so is an IP address — use a hostname.
  3. List the events you want, comma- or space-separated (see Events). Leave the box blank to receive every event.
  4. Click Create webhook and copy the signing secret — it is shown once. You need it to verify signatures.

Deliveries go from the platform’s servers to public hosts only — a URL that resolves to a private network address is refused at creation and again at delivery time.

To change a webhook’s URL or events, Revoke it and create a new one. Revoking stops deliveries immediately and drops any pending retries.

An event name is <entity>.<action>, all lowercase. The entity part is the entity’s API name — the same name used in API paths such as /api/v1/entities/contact/records: contact, company, and deal in every workspace, plus any entity your vertical pack added (the Lead Gen pack adds campaign).

EventFires when
<entity>.createdA record is created, however it got there — in the app, through the API, by an import, from LinkedIn (Add to CRM, Signals), or by a meeting booking.
<entity>.updatedA record’s fields, owner, or pipeline stage change — in the app, through the API, or when a LinkedIn import fills in blanks on an existing contact.
<entity>.deletedA record is deleted — in the app or through the API.

So a webhook subscribed to contact.created deal.updated hears about new contacts and changed deals, and nothing else.

Worth knowing:

  • Only the action half is checked when you create the webhook. A misspelled entity (contacts.created) is accepted and simply never fires — if the Deliveries page stays empty, check the spelling.
  • Notes, tasks, meetings, and emails are activities, not records, and do not send events.
  • Two kinds of background change do not send updated events: details the LinkedIn extension fills in on its own while you browse (LinkedIn identity fields, profile details, a changed job title), and renaming a pipeline stage, which moves every record in that stage at once.

Every delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
X-CRM-Signaturet=<unix seconds>,v1=<hex HMAC> — see Verifying signatures
X-CRM-EventThe event name, for example contact.updated
X-CRM-Delivery-IdA UUID for this delivery — the same on every retry of it
User-Agentgigadev-crm-webhooks/1.0

The body has the same envelope for every entity:

{
"event": "contact.updated",
"tenantId": "5b0e…",
"occurredAt": "2026-09-24T17:03:12.481Z",
"recordId": "9f1c…",
"data": {
"id": "9f1c…",
"entityTypeId": "…",
"values": { "firstName": "Jane", "lastName": "Doe", "stage": "Follow up" },
"…": "…"
},
"previous": { "…": "the record before the change" }
}
FieldWhat it holds
eventThe event name (matches the X-CRM-Event header).
tenantIdYour workspace’s id.
occurredAtWhen the change happened (ISO 8601, UTC). Unchanged across retries.
recordIdThe record’s id.
dataThe record — after the change for created and updated, as it was just before for deleted.
previousupdated only: the record before the change, so you can see which fields moved.

data and previous use the same shape as the EntityRecord schema in the API reference: the record’s values (keyed by each field’s API field key — see Entities and fields) plus its id, owner, created/updated stamps, and so on. Webhooks carry every field of the record, including ones some roles cannot see in the app — point them only at systems you trust with that data.

Every delivery is signed with the webhook’s signing secret, so you can prove it came from your workspace and was not altered.

  • Header: X-CRM-Signature: t=<timestamp>,v1=<signature>
  • Algorithm: HMAC-SHA256, hex-encoded.
  • Key: the signing secret exactly as shown (64 characters) — use the string as-is, do not hex-decode it.
  • What is signed: the timestamp, a period, and the raw request body — <t>.<body>. Verify against the bytes you received, before parsing the JSON; re-serializing the parsed body can change it and break the match.
  • Timestamp: t is Unix seconds, stamped fresh on every attempt (retries included). Reject a delivery whose timestamp is more than about 5 minutes from your clock, so a captured request cannot be replayed later.

A Node.js example (Express, with the body kept raw):

import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
function isValidSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries((header ?? '').split(',').map((p) => p.trim().split('=')))
if (!parts.t || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest()
const received = Buffer.from(parts.v1, 'hex')
return received.length === expected.length && timingSafeEqual(received, expected)
}
const app = express()
app.post('/crm-webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValidSignature(req.body, req.get('X-CRM-Signature'), process.env.CRM_WEBHOOK_SECRET)) {
return res.sendStatus(401)
}
const event = JSON.parse(req.body)
// Skip it if you've already handled req.get('X-CRM-Delivery-Id'), then process event.
res.sendStatus(200)
})

A 4xx answer is final (see Retries), so a wrong secret on your side turns every delivery into Given up. Watch the Deliveries page while you set up.

Deliveries are sent by a background job shortly after the change, not instantly. How your response is treated:

  • Any 2xx — delivered. Nothing more is sent.
  • Any 4xx — treated as a problem on your side and not retried; the delivery is marked Given up.
  • Anything else — a 5xx, a redirect, no answer within 30 seconds, or a connection failure — is retried. Redirects are never followed: point the webhook at the final URL.

A failing delivery is retried 30 seconds, 2 minutes, 10 minutes, 1 hour, and 6 hours after each failed attempt — six attempts in all, over roughly seven hours — and is then marked Given up. Those gaps are minimums; the background job can add a little to each.

Answer quickly with a 2xx and do the real work afterward. Only the first 4 KB of your response body is read. Because a delivery can be retried after your system already processed it, and a retried older event can arrive after a newer one, de-duplicate on X-CRM-Delivery-Id and order by occurredAt rather than by arrival.

Each webhook’s Deliveries page shows its last 50 deliveries, newest first: the event, the number of attempts, the last HTTP status (or network_error), the state — Succeeded, Pending, or Given up — when the next attempt is due, and when the delivery was created. To test end to end, create, edit, or delete a record that matches the webhook’s events and watch the row appear.