# Guide to Navattic Webhooks: Features and Best Practices

Navattic webhooks send your demo engagement data wherever you need it. When a visitor finishes a session in one of your interactive or AI agent demos, Navattic sends the session, the steps they viewed, who they are and which company they work for, so you can score leads, alert the account owner or load the data into your warehouse.

This guide covers what Navattic's Webhook integration sends and when, the two action types, how to authenticate requests with the `Authorization` header, and the practices that keep a receiver reliable when much of the delivery behaviour is undocumented.

## What are Navattic webhooks?

Navattic sends an event to your Webhook integration whenever a visitor session in your demo is marked as closed. There is one event type, `session_closed`, and two webhook action types that share the same envelope:

* A **Visitor event** carries the full session, the visitor and their company account.
* An **Account event** carries only the company account.

Navattic doesn't sign webhook requests. Each request has `Content-Type: application/json` and `User-Agent: navattic-tour`, and, only when you configure one on the webhook, an `Authorization` header with `Basic <credentials>` or `Bearer <token>`. That header is the only way to tell a Navattic request from anyone else's.

## Navattic webhook features

| Feature | Details |
| --- | --- |
| Trigger | A visitor session in a demo is marked closed |
| Event type | `session_closed`, currently the only value |
| Action types | Visitor event or Account event |
| Format | JSON, `Content-Type: application/json` |
| User agent | `navattic-tour` |
| Authentication | Optional `Authorization` header: `Basic <credentials>` or `Bearer <token>` |
| Signature and timestamp header | None |
| HTTP method | Not documented |
| Retries and timeout | Not documented |
| Source IPs | Not published |
| Change policy | Navattic aims to minimise changes and gives notice before breaking changes |

## Payload

The envelope has three fields: `eventType`, `timestamp` (when the session closed, ISO 8601) and `data`. A Visitor event, trimmed from Navattic's example:

```json
{
  "eventType": "session_closed",
  "timestamp": "2026-07-14T18:32:05.000Z",
  "data": {
    "session": {
      "type": "INTERACTIVE_DEMO",
      "recordedGuideSessionId": "clx8f2k1a0003abcd12345678",
      "createdAt": "2026-07-14T18:20:11.000Z",
      "closedAt": "2026-07-14T18:32:05.000Z",
      "mostRecentDemoName": "Acme Product Tour",
      "recordedEvents": [
        {
          "id": "clx8f3xyz0004abcd12345678",
          "type": "VIEW_STEP",
          "recordedStepName": "Welcome",
          "recordedFlowName": "Getting Started"
        }
      ]
    },
    "endUser": {
      "id": "clx8f1hhh0010abcd12345678",
      "mostRecentEmail": "jane.doe@example.com",
      "mostRecentCompany": "Example Corp",
      "converted": true,
      "engaged": true
    },
    "companyAccount": {
      "id": "clx8f4iii0011abcd12345678",
      "name": "Example Corp",
      "websiteDomain": "example.com",
      "employeeCount": 250,
      "estimatedAnnualRevenue": "50000000"
    }
  }
}

```

A few details matter for parsing:

* `session.type` is `INTERACTIVE_DEMO` or `AGENT_DEMO`, and each adds its own fields.
* `companyAccount` is `null` on a Visitor event when no account matched.
* For an Account event, `data` is the company account object itself, not wrapped in `companyAccount`.
* Large numbers, such as `estimatedAnnualRevenue`, are strings.
* When a Launchpad presentation is linked to a Salesforce opportunity, the payload adds `opportunityOwnerEmail` and `opportunityOwnerId`.

Nothing in the body names the action type, so check for `data.session` to tell a Visitor event from an Account event.

> **Inspect Navattic webhooks as they arrive.** Navattic warns that public testing services can expose customer data. A [Hookdeck Inspect](https://inspect.hookdeck.com) URL shows the exact method, headers and body Navattic sends, with no account or setup required; send test sessions only.

## Setting up Navattic webhooks

Generate a long random token first, for example with `openssl rand -hex 32`.

1. In Navattic, add the **Webhook** integration.
2. Enter your HTTPS endpoint URL.
3. Choose the action type: Visitor event or Account event.
4. Configure an `Authorization` value on the webhook, either a Bearer token or Basic credentials. Without it, Navattic sends no `Authorization` header at all.
5. Complete a session in one of your demos and check that the event arrives.

Navattic's docs describe the payload and headers but not every label in the integration screen, so match these steps to what you see there.

**With Hookdeck.** Navattic is a supported source in Hookdeck. Its setup note reads: "Paste the full Authorization value Navattic sends, including the "Basic " or "Bearer " prefix." Create a source with the **Navattic** type and use its URL as the webhook URL. In **Authorization Header**, paste the whole value, for example `Bearer your-long-random-token`; for Basic, paste `Basic` followed by the base64 credentials, not the username and password. The source compares the whole header with that value.

Navattic doesn't document the HTTP method, so the source pre-selects `POST` without managing it. After your first test session, look at the request in Hookdeck; if its method isn't `POST`, allow that method on the source too, or requests with it won't be accepted. A request with a missing or wrong `Authorization` value still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

For local development, `hookdeck listen 3000 navattic --path /webhooks/navattic` gives you a public URL that forwards to your machine.

## Securing Navattic webhooks

Compare the whole `Authorization` header with the value you configured, prefix included, in constant time. There's no need to decode Basic credentials; comparing the full string checks both parts at once.

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

// The full value, e.g. "Bearer your-long-random-token".
const EXPECTED = process.env.NAVATTIC_AUTHORIZATION;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// The method isn't documented, so accept any and check the header first.
app.all('/webhooks/navattic', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('authorization');
  if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  if (event.eventType === 'session_closed') {
    const isVisitorEvent = Boolean(event.data && event.data.session);
    // Queue the session or account for processing.
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

EXPECTED = os.environ.get("NAVATTIC_AUTHORIZATION", "")  # e.g. "Bearer your-token"

def verify_navattic_request(headers) -> bool:
    if not EXPECTED:
        return False  # never accept when unconfigured
    received = headers.get("Authorization")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), EXPECTED.encode("utf-8"))

```

A static `Authorization` value authenticates the sender, not the body, so a modified payload still passes, and nothing stops a captured request from being replayed. Use HTTPS only and a long random value. These payloads carry visitors' names, email addresses and locations, so treat the endpoint and its logs as personal data.

> **Make Navattic webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `Authorization` header, stores every closed session durably, and lets you retry and replay delivery to your CRM or warehouse.

## Navattic webhook limitations and pain points

### Delivery behaviour is undocumented

**The Problem:** You don't know the method, how long Navattic waits, or whether a failed request is retried.

**Why It Happens:** Navattic's Webhook page documents the payload and headers, but not the delivery mechanics.

**Workarounds:**

* Capture a real request to learn the method, and accept it explicitly.
* Respond with a `200` quickly, and alert when a demo with traffic produces no events for longer than usual.

**How Hookdeck Can Help:** Hookdeck accepts each request as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, so an outage on your side doesn't depend on whether Navattic retries.

### One event type, two shapes

**The Problem:** A handler written for Visitor events breaks on an Account event, or the other way round.

**Why It Happens:** Both action types send `eventType: session_closed`. For Account events, `data` is the company account itself.

**Workarounds:**

* Give each action type its own webhook URL or path.
* If they share a URL, check for `data.session` before reading anything else.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on the presence of `data.session`, so each shape reaches its own handler.

### Basic credentials as a pasted value

**The Problem:** Hookdeck's source or your receiver rejects every request after you switch between Bearer and Basic.

**Why It Happens:** The comparison is on the full header string. A Basic value has to be compared as `Basic <base64>`, and the base64 string changes with any change to the username or password.

**Workarounds:**

* Prefer a Bearer token: it's one random string, easy to rotate.
* After any change, capture one request and copy the `Authorization` value exactly.

**How Hookdeck Can Help:** Hookdeck keeps failed-verification requests with their headers, so you can compare what Navattic sent with what the source expects.

## Best practices

### Deduplicate on the session

Use `data.session.recordedGuideSessionId` for Visitor events, and the account `id` plus `timestamp` for Account events, so a repeated delivery doesn't create duplicate CRM activity. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat custom properties as optional

`customPropertyValues` depends on your workspace's properties and how each was captured (`source` values such as `FORM` or `ENRICHMENT`). Look properties up by name and handle missing ones.

### Don't use `visitorOpportunityId` as an origin signal

Navattic warns that it lives on the visitor record and persists until a newer value replaces it, so every session after a single Launchpad touch carries it forward. Use `session.presentation` to identify sessions that came through a presentation, and `opportunityOwnerEmail` to route to the right rep.

### Acknowledge fast and process asynchronously

Store the payload, return `200`, and push to your CRM in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Navattic's Webhook integration sends a `session_closed` event when a demo session closes, as a Visitor event with the session, visitor and account or an Account event with the account alone. There's no signature, so configure an `Authorization` value, compare the full header in constant time, tell the two shapes apart by `data.session`, and confirm the undocumented method and retry behaviour with a real capture.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, stores every session, and retries and replays delivery so demo engagement data reaches your systems.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Navattic webhooks reliably in minutes.