Gareth Wilson Gareth Wilson

Guide to Chameleon Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationIntegrations > Webhooks (app.chameleon.io/integrations/webhooks)
SubscriptionPer endpoint, by topic
Signaturehex HMAC-SHA256
Signature headerx-chameleon-signature
Signed contentThe raw request body
SecretOne Webhook Secret per account
Other headersX-Chameleon-Id, User-Agent: Chameleon Webhooks/v3 (chameleon.io; integral)
BodyJSON
Event routingBody field kind
Timeout7 seconds
RetriesA non-200 is retried 9 times over 43 hours. Any 2xx is success
Health checksping events
Source IPs34.206.31.185, 54.198.191.211, 54.211.111.28, 54.237.132.170

Events and payloads

EventTriggered WhenCommon Use Cases
pingChameleon checks the endpoint is workingNothing to do beyond verifying it
tour.startedA Tour starts (includes the first Step)Funnel tracking
tour.completedA Tour is completedMark onboarding done in your CRM
tour.exitedA Tour is exited (includes the exited Step)Find drop-off Steps
tour.snoozedA Tour is exited on a Step configured to snoozeRe-engagement
tour.button.clickedA Tour button is clickedCustom button actions
survey.started, survey.completed, survey.exited, survey.snoozed, survey.button.clickedThe Microsurvey lifecycleSurvey analytics
response.finishedA Microsurvey is finished (all Steps, plus any text comment)Store NPS/CSAT answers
embed.started, embed.completed, embed.exited, embed.snoozed, embed.button.clickedThe Embed lifecycleEmbedded content analytics
demo.startedA Demo is startedLead tracking
demo.revealA Demo started with Clearbit reveal and a company matchedEnrich leads (reveal_domain, reveal_name)
demo.finishedThe last step was reached, or about 30 minutes passed (finished_kind is last_step or timeout_30m)Score engagement
demo.form.submittedA Demo form is submittedCapture leads
demo.email.addedAn email is added to a Demo run (shown only in an example; sent once per updated run)Capture leads
helpbar.searchHelpBar search results are displayedSearch analytics
helpbar.answerA search generates an AI AnswerFind content gaps
helpbar.item.actionA HelpBar item is clicked or actionedItem popularity
helpbar.item.errorA HelpBar item's action throws an errorAlerting
alert.triggeredAn Alert's conditions are violatedNotify 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. It is the complete example from Chameleon's outgoing webhooks page.

{
  "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:

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.

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.

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 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 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 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 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 for free and handle Chameleon webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.