# Guide to Agntix Webhooks: Features and Best Practices

Agntix runs AI agents that answer and place phone calls, and hold chat conversations on the web and WhatsApp. Its webhooks tell your systems when one of those sessions starts and when it ends, and the end event carries the post-call analysis: a summary, plus intent, resolution, sentiment and extracted data if you have switched those on for the agent. Most integrations use them to write call outcomes back to a CRM or to start a follow-up task once a call closes.

## What are Agntix webhooks?

Agntix delivers its webhooks through Svix. You add an endpoint in the dashboard, tick the events it should receive, and Agntix sends each one as a JSON `POST` with three Svix headers: `svix-id`, `svix-timestamp` and `svix-signature`. The signature follows the Standard Webhooks scheme, so any Svix or Standard Webhooks verifier can check it.

The event surface is small. Two events are delivered today, `session.created` and `session.ended`. Agntix's event reference describes several more, but labels them as planned and says they are not delivered as webhooks yet.

## Agntix webhook features

| Feature | Details |
| --- | --- |
| Delivery platform | Svix |
| Configuration | Dashboard, **Developer > + Add Endpoint** |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature`, with `svix-id` and `svix-timestamp` |
| Secret | One per endpoint, in the Developer section |
| Body | JSON |
| Event routing | Body field `eventType` |
| Response window | Any `2xx` within 15 seconds |
| Retries | At least once, exponential backoff for up to 24 hours, then dead-lettered |
| Ordering | Not guaranteed. Sort on `createdAt` |
| Rate limit | Optional requests-per-second cap per endpoint |
| Endpoints | Public HTTPS URLs only |
| Source IPs | Not published |

## Events and payloads

| Event | Triggered When | Notable fields |
| --- | --- | --- |
| `session.created` | A session is initiated. For outbound calls this is before pickup | `id`, `type`, `platform`, `direction`, `state` (`OPEN`), `agentId`, `createdAt`, `metadata` |
| `session.ended` | A session closes (hangup, timeout, TTL expiry or manual close) | the above with `state` `CLOSED`, plus `lastActivityAt`, `endedAt`, `analysis`, `recordingUrl` |

The body is flat, with no `data` wrapper. Three fields are easy to mix up:

* `eventType` is the event name. Route on this.
* `type` is the session kind, `VOICE` or `CHAT`. It is not the event name.
* `id` is the session UUID. Both events for one session carry the same `id`, so a deduplication key needs `eventType` as well.

The rest of the envelope describes the session: `platform` (`PHONE`, `WEB` or `WHATSAPP`), `direction` (`INBOUND` or `OUTBOUND`), `state` (`OPEN`, then `CLOSED`), `agentId`, `createdAt` and `metadata`. `metadata.sessionVariables` returns the `session_variables` you passed when you triggered the session, unchanged, which is how you tie a webhook back to your own record.

`session.ended` adds `lastActivityAt`, `endedAt`, `recordingUrl` and `analysis`. `analysis.summary` is always there. `intent`, `resolution` (`RESOLVED`, `PARTIALLY_RESOLVED`, `UNRESOLVED` or `NOT_APPLICABLE`), `sentiment` (`POSITIVE`, `NEUTRAL` or `NEGATIVE`, with a score from -1.0 to 1.0) and `extractedData` appear only when the matching feature is enabled on the agent.

This is the `session.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/agntix/latest/session.created.json):

```json
{
  "id": "4981804c-2658-4893-84ff-9c2fa90fb3c2",
  "eventType": "session.created",
  "type": "VOICE",
  "platform": "PHONE",
  "direction": "OUTBOUND",
  "state": "OPEN",
  "agentId": "40235145-aa3d-443e-9d0c-841dd0716a2f",
  "createdAt": "2026-04-08T05:00:01.130Z",
  "metadata": {
    "customerPhoneNumber": "+971501234567",
    "roomName": "outbound_sip_40235145-aa3d-443e-9d0c-841dd0716a2f^__^7yoamyb",
    "sessionVariables": {
      "crmContactId": "CRM-7728",
      "customerName": "Alex Morgan"
    }
  }
}

```

For an outbound voice call, `session.created` fires before the callee picks up, so don't treat it as proof that anyone answered.

## Setting up Agntix webhooks

1. In the Agntix dashboard, open **Developer** (under *Integrations*) and click **+ Add Endpoint**.
2. Enter the **Endpoint URL**, a **Description**, and optionally a **Rate Limit** in requests per second. Set it comfortably above your peak call volume.
3. Under **Subscribe to events**, tick `session.created`, `session.ended` or both, and save.
4. Copy the endpoint's signing secret from the Developer section and store it as `AGNTIX_WEBHOOK_SECRET`.

To correlate events with your CRM, pass your own identifiers in `session_variables` when you trigger a call or chat:

```json
"session_variables": { "crmContactId": "CRM-7728", "campaignId": "CAMP-2026-05-13-001", "taskId": "TASK-9911" }

```

Agntix echoes those keys under `metadata.sessionVariables` on both events, whether or not the agent's prompt uses them.

To route Agntix through Hookdeck, create a source with the **Agntix** type, paste the endpoint's signing secret, and use the source URL as the endpoint URL in Agntix. During development, the [Hookdeck CLI](/docs/cli) forwards those deliveries to a local port:

```bash
npx hookdeck-cli listen 3000 agntix --path /webhooks/agntix

```

## Securing Agntix webhooks

Agntix's own sample verifies with the `svix` package. That works for a direct receiver, but the Svix library rejects any `svix-timestamp` more than five minutes from the current time. Hookdeck keeps the original headers when it retries or replays a request and applies no timestamp tolerance of its own, so a delivery held during an outage on your side would fail the library check. The verifier below checks the HMAC without an age limit.

It builds `<svix-id>.<svix-timestamp>.<raw body>`, keys an HMAC-SHA256 with the base64 bytes after `whsec_`, and accepts any `v1,` entry in the space-separated `svix-signature` header. If `svix-id` is missing it reads the `webhook-*` names instead, as the Hookdeck source does.

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

A receiver for Agntix stores the verified event under a key built from the session ID and the event name, then acknowledges well inside the 15-second window:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// saveEvent(key, event) writes to your own durable store and resolves once it is stored.
function createAgntixReceiver(saveEvent) {
  const app = express();
  app.post('/webhooks/agntix', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.AGNTIX_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    if (typeof event.eventType !== 'string') return res.sendStatus(400);
    // Both events share the session id, so the key needs the event name too.
    const eventKey = `${event.id}:${event.eventType}`;
    try {
      await saveEvent(eventKey, event);
    } catch {
      return res.sendStatus(503); // Agntix retries with backoff
    }
    return res.sendStatus(200);
  });
  return app;
}

```

A worker then reads `session.ended` records and writes `analysis` and `metadata.sessionVariables` back to the CRM. If a request reaches a Hookdeck Agntix source with a bad signature, Hookdeck still answers `200` and marks it as failed verification rather than returning the `401` shown above.

> **Make Agntix webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Svix signature on every Agntix session event, keeps a searchable history of each call outcome, and retries delivery to your CRM sync on your schedule.

## Agntix webhook limitations and pain points

### Only two events are live

**The Problem:** Handlers written against `session.updated`, `message.added` or `usage.updated` never fire.

**Why It Happens:** Agntix documents those events so customers can design for them, but marks them as planned and "not delivered as webhooks yet". `session.updated` and `knowledge_store.updated` exist only as in-app real-time signals.

**Workarounds:**

* Subscribe to `session.created` and `session.ended` only, and acknowledge any other `eventType` with `200` so new events don't cause failures when they ship.
* Poll the API for mid-session state if you need it now.

**How Hookdeck Can Help:** A Hookdeck filter on `eventType` can send the two live events to your service and hold anything new for inspection until you are ready for it.

### The payload is a summary, not a transcript

**The Problem:** You need what was said on the call, and the webhook doesn't have it.

**Why It Happens:** Agntix describes the webhook as "summary-grade, not transcript-grade". The message-by-message transcript is left out, and some fields can be truncated on very long calls.

**Workarounds:**

* Treat `session.ended` as the trigger and fetch the transcript from `GET /api/v1/chat/sessions/{id}/messages`.

**How Hookdeck Can Help:** Hookdeck's [transformations](/docs/transformations) can trim or reshape the analysis before it reaches each destination, but the transcript itself still has to come from the API.

### Events arrive out of order

**The Problem:** A `session.ended` can reach you before the `session.created` for the same call, especially when many calls run in parallel.

**Why It Happens:** Agntix states that order is not guaranteed, and a retried `session.created` can land after the session has already closed.

**Workarounds:**

* Upsert on the session `id` and let the `CLOSED` state win.
* Sort on `createdAt` when you need a timeline.

**How Hookdeck Can Help:** Hookdeck records each delivery with its arrival time, so you can see the order events actually reached you and replay them while you debug a sync.

### The signing secret's format isn't documented

**The Problem:** A verifier that expects `whsec_` rejects every request, and you can't tell from the docs whether the secret is wrong.

**Why It Happens:** Agntix never shows the secret's form. Svix-issued secrets start with `whsec_`, and the Svix library and Hookdeck's source both base64-decode the part after that prefix, but Agntix doesn't confirm it.

**Workarounds:**

* Check the secret in the Developer section before you deploy. If it doesn't start with `whsec_`, ask Agntix support how to use it.

**How Hookdeck Can Help:** Hookdeck's request log shows the exact headers and body of a failed verification, so you can tell a secret mismatch from a body that was changed in transit.

## Best practices

### Correlate through `session_variables`

Put a CRM contact ID or task ID in `session_variables` every time you trigger a session. Calls from one campaign look alike otherwise, and `metadata.sessionVariables` is the only field that links a webhook to your record.

### Key deduplication on session and event

Agntix delivers at least once and recommends the session `id` as the idempotency key. Because `session.created` and `session.ended` share that ID, store `id` plus `eventType`. The [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) explains the pattern.

### Check for optional analysis fields

Only `analysis.summary` is guaranteed. Read `intent`, `resolution`, `sentiment` and `extractedData` with optional access, because switching a feature off on the agent removes the field from later calls.

### Size the endpoint rate limit

The **Rate Limit** setting caps requests per second per endpoint. Agntix suggests setting it comfortably above peak, for example `50` for bursts of 10 concurrent calls, so the cap never becomes the reason events arrive late.

## Conclusion

Agntix sends one event when a session opens and another, carrying the analysis, when it closes. Correlate the two through `session_variables`, and treat the planned events as unavailable until they're live.

[Hookdeck Event Gateway](https://hookdeck.com) can sit between Agntix and your CRM sync, verifying the Svix signature and holding call outcomes while your service is down. [Create a free Hookdeck account](https://dashboard.hookdeck.com/signup) to try it with an Agntix endpoint.