# Guide to Chameleon Webhooks: Features and Best Practices

Chameleon puts product tours, microsurveys, embedded cards, interactive demos and a search-and-help bar inside a SaaS product without engineering work. Its webhooks send the resulting user activity to your own systems: who started or abandoned an onboarding tour, how someone answered an NPS survey, which company watched a demo and filled in its form, what people searched for in the HelpBar. Product, growth and success teams use them to feed a CRM, a warehouse or a lead-routing tool in real time.

This guide covers Chameleon's topics and payload shape, how to verify `X-Chameleon-Signature`, where the `sent_at` replay check fits, and how Chameleon's timeout and retry schedule should shape your receiver.

## What are Chameleon webhooks?

Chameleon calls them outgoing webhooks. On the **Integrations > Webhooks** page you add one or more HTTPS endpoints and pick the topics each one receives. Chameleon sends each event right away from AWS us-east as a `POST` with a JSON body.

The account has one **Webhook Secret**, and every delivery carries `X-Chameleon-Signature`, a hex HMAC-SHA256 of the raw body keyed with it. Chameleon also sends `ping` events to check that each endpoint is working, so a receiver sees traffic even when nobody is using your product.

## Chameleon webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Integrations > Webhooks** (app.chameleon.io/integrations/webhooks) |
| Subscription | Per endpoint, by topic |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-chameleon-signature` |
| Signed content | The raw request body |
| Secret | One Webhook Secret per account |
| Other headers | `X-Chameleon-Id`, `User-Agent: Chameleon Webhooks/v3 (chameleon.io; integral)` |
| Body | JSON |
| Event routing | Body field `kind` |
| Timeout | 7 seconds |
| Retries | A non-200 is retried 9 times over 43 hours. Any `2xx` is success |
| Health checks | `ping` events |
| Source IPs | `34.206.31.185`, `54.198.191.211`, `54.211.111.28`, `54.237.132.170` |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `ping` | Chameleon checks the endpoint is working | Nothing to do beyond verifying it |
| `tour.started` | A Tour starts (includes the first Step) | Funnel tracking |
| `tour.completed` | A Tour is completed | Mark onboarding done in your CRM |
| `tour.exited` | A Tour is exited (includes the exited Step) | Find drop-off Steps |
| `tour.snoozed` | A Tour is exited on a Step configured to snooze | Re-engagement |
| `tour.button.clicked` | A Tour button is clicked | Custom button actions |
| `survey.started`, `survey.completed`, `survey.exited`, `survey.snoozed`, `survey.button.clicked` | The Microsurvey lifecycle | Survey analytics |
| `response.finished` | A Microsurvey is finished (all Steps, plus any text comment) | Store NPS/CSAT answers |
| `embed.started`, `embed.completed`, `embed.exited`, `embed.snoozed`, `embed.button.clicked` | The Embed lifecycle | Embedded content analytics |
| `demo.started` | A Demo is started | Lead tracking |
| `demo.reveal` | A Demo started with Clearbit reveal and a company matched | Enrich leads (`reveal_domain`, `reveal_name`) |
| `demo.finished` | The last step was reached, or about 30 minutes passed (`finished_kind` is `last_step` or `timeout_30m`) | Score engagement |
| `demo.form.submitted` | A Demo form is submitted | Capture leads |
| `demo.email.added` | An email is added to a Demo run (shown only in an example; sent once per updated run) | Capture leads |
| `helpbar.search` | HelpBar search results are displayed | Search analytics |
| `helpbar.answer` | A search generates an AI Answer | Find content gaps |
| `helpbar.item.action` | A HelpBar item is clicked or actioned | Item popularity |
| `helpbar.item.error` | A HelpBar item's action throws an error | Alerting |
| `alert.triggered` | An Alert's conditions are violated | Notify your team |

For survey answers, `response.finished` is usually the event you want: it fires once all Steps are done, including any text comment, whereas `survey.completed` fires on the Step the user completed. `demo.finished` can fire on a timeout rather than on the last step, so check `finished_kind` before scoring a lead as engaged.

### Event Payload Structure

* `id`: the Chameleon ID of the webhook (also sent as the `X-Chameleon-Id` header). Use it to dedupe.
* `kind`: the topic.
* `sent_at`: server time when it was sent. It isn't signed separately; it is just part of the body.
* `data`: the models included with the event, keyed by singular or plural name, such as `profile`, `tour`, `step` and `action`. A tour event carries the user's `profile`, the `tour` and the `step` involved.

### Published payload example

This is the `ping` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/chameleon/latest/ping.json). It is the complete example from Chameleon's outgoing webhooks page.

```json
{
  "id": "5fb70dcbc39330000325a817",
  "kind": "ping",
  "sent_at": "2029-12-11T00:28:59.650Z",
  "data": {
    "account": {
      "id": "58918bc07de121000432e9c0",
      "domain": "acme.co",
      "name": "Acme Corp"
    }
  }
}

```

`ping` is the only complete example Chameleon publishes. The others elide fields, so record a real delivery of each topic you depend on.

## Setting up Chameleon webhooks

1. In the Chameleon dashboard, go to **Integrations** > **Webhooks** (app.chameleon.io/integrations/webhooks).
2. Copy the account's **Webhook Secret** and store it as `WEBHOOK_SECRET`. Use the value exactly as shown. It is the HMAC key as UTF-8 text, not hex or base64 to decode.
3. Add an endpoint with your `https://` URL, for example `https://example.com/webhooks/chameleon`.
4. Select the topics it should receive, for example `tour.completed`, `response.finished` and `demo.form.submitted`.
5. Save. Chameleon starts sending `ping` events to check the endpoint, so verify and acknowledge them with a `200`.

Chameleon's docs recommend also accepting requests only from its IP addresses, listed on its authentication page, with changes announced on its status page. Apply that at your firewall as well as verifying the signature.

To try it from your machine, forward deliveries with the [Hookdeck CLI](/docs/cli):

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

```

## Securing Chameleon webhooks

Compute an HMAC-SHA256 of the raw request body with the Webhook Secret, encode it as hex and compare it with `X-Chameleon-Signature` in constant time. There is no `sha256=` prefix. Chameleon's Rails sample reads `request.raw_post` and uses `secure_compare`. See the [verification documentation](https://developers.chameleon.io/webhooks/outgoing).

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-chameleon-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler verifies, answers `ping` without further work, skips webhooks it has already stored, and saves the rest before replying.

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

function createChameleonApp(events) {
  const app = express();
  app.post('/webhooks/chameleon', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { id, kind, sent_at: sentAt, data } = parsed;
    if (typeof kind !== 'string') return res.sendStatus(400);
    if (kind === 'ping') return res.sendStatus(200);

    try {
      // Optional, as in Chameleon's sample: reject if sentAt is more than a few minutes old.
      // Leave it out if a gateway may retry or replay the delivery later.
      await events.saveOnce(id, { kind, sentAt, profile: data?.profile, data });
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Chameleon's sample goes one step further and rejects a body whose `sent_at` is more than 5 minutes from the current time, as protection against replays. That check is yours to make, not something Chameleon enforces, and `sent_at` is inside the signed body rather than in a separate signed header.

Hookdeck's Chameleon source type verifies `X-Chameleon-Signature` as a hex HMAC-SHA256 of the raw body. It does not check `sent_at`, and it applies no timestamp tolerance, so its retries and replays verify whenever they run. Hookdeck always answers Chameleon with `200`, records a failed check as failed verification and does not forward that request.

> **Make Chameleon webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Chameleon inside its 7-second timeout, verifies `X-Chameleon-Signature`, and routes tour, survey and demo events to the systems that need them.

## Chameleon webhook limitations and pain points

### A 7-second timeout

**The Problem:** Deliveries that do real work, such as creating a lead in a CRM, time out and get retried.

**Why It Happens:** Chameleon gives your endpoint 7 seconds. A handler that calls another API before answering can easily exceed that, and the retry then sends the same event again.

**Workarounds:**

* Store the event and answer `2xx` straight away, then process it in a background job.
* Deduplicate on `id` so a retry after a slow first attempt is harmless.

**How Hookdeck Can Help:** Hookdeck answers Chameleon immediately and delivers to your service with its own timeout and retry settings.

### The `sent_at` check and late deliveries

**The Problem:** Retried or replayed deliveries are rejected by your own replay check.

**Why It Happens:** Chameleon recommends rejecting bodies whose `sent_at` is more than a few minutes old. But Chameleon's own retries run over 43 hours, and a gateway's retries and replays resend the original body with its original `sent_at`. A strict window rejects exactly the deliveries you most need after an outage.

**Workarounds:**

* If you apply the check, apply it only on traffic that comes directly from Chameleon.
* Rely on `id` deduplication for replay protection instead.

**How Hookdeck Can Help:** Hookdeck verifies the signature without a time limit, so events retried or replayed after an outage still reach your service, and its deduplication can drop repeats of the same `id`.

### One secret for every endpoint

**The Problem:** Rotating the secret affects every integration at once.

**Why It Happens:** The Webhook Secret belongs to the account, not to the endpoint. Every endpoint on the Webhooks page is signed with the same key.

**Workarounds:**

* Keep the secret in one shared secret store so all receivers update together.
* Plan rotations as a coordinated change across every consumer.

**How Hookdeck Can Help:** Point every Chameleon endpoint at one Hookdeck source and fan events out to your consumers from there, so the secret lives in one place.

### Inconsistent topic documentation

**The Problem:** You can't tell from the docs exactly when some events fire.

**Why It Happens:** Several descriptions in Chameleon's topic table look copied: `tour.button.clicked` and `survey.button.clicked` are described as exits, and `survey.snoozed` describes a Tour. `demo.email.added` appears only in an example payload, not in the table.

**Workarounds:**

* Subscribe in a test account and record what each topic actually sends.
* Route on `kind` and log anything unexpected rather than failing.

**How Hookdeck Can Help:** Hookdeck's event log shows every topic that arrives with its full payload, which is the quickest way to learn the real behaviour.

## Best practices

### Verify against the raw body

Parsing and re-serialising JSON changes whitespace and key order. Use `express.raw({ type: '*/*' })` in Express, `await request.arrayBuffer()` in Next.js and `await request.body()` in FastAPI. Check that the header is 64 hex characters before decoding it.

### Treat `ping` as a health check

Verify `ping`, answer `200` and do nothing else. Don't let it create records or trigger alerts.

### Deduplicate on `id`

The webhook's `id` is also in `X-Chameleon-Id`. Store it with the change it causes so that Chameleon's retries don't double-count a survey response or a demo lead. See [implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency) for the storage pattern.

### Filter by topic at the source

Each endpoint can subscribe to its own topics. Send survey responses to one endpoint and demo leads to another, rather than receiving everything and discarding most of it.

### Process asynchronously

Seven seconds is not long, which is why [asynchronous processing](/webhooks/guides/why-implement-asynchronous-processing-webhooks) matters here.

## Conclusion

Chameleon's webhooks carry useful product and lead data, signed in a simple way. The decisions that matter are around timing: a 7-second timeout, retries spread over 43 hours, and a recommended `sent_at` check that conflicts with late delivery. Verify the HMAC, deduplicate on `id`, and keep any freshness check optional.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each Chameleon delivery, answers within the timeout and gives you a replayable log of every tour, survey and demo event.

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