Events and webhooks
To react to what happens in a workspace, either poll the events feed or receive the same events at a webhook endpoint. Both carry the same envelope and the same ids.
The envelope
{
"id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"type": "reply.received",
"occurred_at": "2026-10-27T12:00:00.000Z",
"workspace_id": "5b0f1c0e-2a64-4d6f-9c7e-1f0a3b8d2e41",
"data": { "conversation_id": "...", "lead_id": "..." }
}id is stable and unique: use it to drop a repeat. A payload holds ids, states and short codes, never a message body or a name. Read the details with the read operations, for a reply GET /workspaces/{workspace_id}/inbox/conversations/{conversation_id}, which apply your own rights.
Event types
| Type | data fields |
|---|---|
reply.received | conversation_id, account_id, campaign_id, lead_id (null when not linked), message_id, received_at |
invitation.sent | account_id, campaign_id, lead_id, campaign_lead_id, action_id, sent_at |
invitation.accepted | account_id, campaign_id, lead_id, campaign_lead_id, accepted_at |
message.sent | account_id, campaign_id, lead_id, campaign_lead_id, conversation_id, message_id (null when not known yet), source (campaign or inbox), sent_at |
message.failed | The fields of message.sent with reason (a short code) and failed_at. One per attempt that did not go out. |
account.status_changed | account_id, from, to, reason |
account.hold_placed | account_id, hold_id, kind, reason, expires_at |
account.hold_lifted | account_id, hold_id, kind, reason |
campaign.status_changed | campaign_id, from, to |
campaign.lead_state_changed | campaign_id, campaign_lead_id, lead_id, account_id, from, to, reason |
import.finished | list_id, rows, imported, attached, duplicates, errors |
limits.reached | account_id, limit (invitations, messages, profile_views), period (daily, weekly). At most once per account, limit and local day. |
An event is recorded together with the change that caused it: a change that did not happen leaves no event, and a change that did always has one. A key sees only the event types and accounts its person may see in the app.
Polling
GET /workspaces/{workspace_id}/events returns events oldest first. It takes limit, since, types (comma separated) and cursor. Keep the next_cursor of the last page and pass it as cursor next time: you will see every event once and none is skipped. next_cursor is set whenever you hold a position, also when nothing new came. complete is true when nothing newer exists right now. The scope is workspace.events.read.
curl -s "https://app.konekt.so/api/v1/workspaces/$WS/events?types=reply.received&cursor=$CURSOR" \
-H "Authorization: Bearer $KEY"Events are kept for 30 days. If you were away longer, read the objects themselves instead.
Webhook endpoints
You can have several endpoints per workspace, each with a name, an https address, the event types it wants and its own signing secret. Make them in the app, or with POST /workspaces/{workspace_id}/webhook-endpoints. There are calls to list, change, delete and test an endpoint (a test event is limited to 5 an hour per endpoint) and to list recent deliveries.
Webhook calls need the scope workspace.webhook.manage, which only a Full access key or a custom key can hold, because saving an address sends your event data to a place the key's holder chooses. They are not available to assistants. The secret is shown once, in the answer of the call that made it, so store it at once. An endpoint receives only events that happen after it was created or switched on.
What a delivery looks like
We send a POST with the envelope as the body and these headers:
| Header | Value |
|---|---|
X-Webhook-Id | The event id. The same on every retry. |
X-Webhook-Timestamp | Unix seconds of this attempt. |
X-Webhook-Signature | v1= and the hex HMAC SHA-256 of <timestamp>.<raw body> under your secret. |
Answer with any 2xx status as soon as you have stored the event. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then given up. A 410 answer, a redirect, and an address that is private or resolves to a private one are not retried. Delivery is at least once and not ordered: deduplicate on id and order by occurred_at. Each request waits at most 15 seconds, and only your status is read.
Verify the signature
Always verify before you trust a delivery. Compute the signature over the exact bytes you received, compare in constant time, and refuse a timestamp more than 300 seconds old so a captured request cannot be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, headers, rawBody, now = Date.now()) {
const ts = headers['x-webhook-timestamp']
if (!/^\d{9,12}$/.test(ts) || Math.abs(now / 1000 + Number(ts) * -1) > 300) return false
const expected = 'v1=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
const given = headers['x-webhook-signature'] ?? ''
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}Use the raw request body, not a parsed and re-written one. In Express that means express.raw({ type: 'application/json' }) on this route.
Polling or webhooks
Use a webhook when you have an address that is always up: it is prompt and needs no loop. Poll when you cannot receive requests, for example from a laptop or an assistant session, or to catch up after being away. The feed is the source for both.