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 theX-Chameleon-Idheader). 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 asprofile,tour,stepandaction. A tour event carries the user'sprofile, thetourand thestepinvolved.
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
- In the Chameleon dashboard, go to Integrations > Webhooks (app.chameleon.io/integrations/webhooks).
- 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. - Add an endpoint with your
https://URL, for examplehttps://example.com/webhooks/chameleon. - Select the topics it should receive, for example
tour.completed,response.finishedanddemo.form.submitted. - Save. Chameleon starts sending
pingevents to check the endpoint, so verify and acknowledge them with a200.
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
2xxstraight away, then process it in a background job. - Deduplicate on
idso 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
iddeduplication 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
kindand 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.