# Chameleon Webhooks

Chameleon (chameleon.io) sends a JSON `POST` to your endpoint when users interact with your
in-app Experiences: a Tour starts or completes, a Microsurvey is answered, an Embed or Demo runs,
a HelpBar search happens, or an Alert fires. Each request is signed with an HMAC-SHA256 of the raw
body in the `X-Chameleon-Signature` header.

## When to Use This Skill

* How do I receive Chameleon webhooks?
* How do I verify the `X-Chameleon-Signature` header?
* How do I handle `tour.completed`, `survey.completed` or `response.finished` events?
* Why does my Chameleon webhook signature verification fail?
* Where do I find the Chameleon Webhook Secret?

## Verification (core)

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

const HEX_SHA256 = /^[0-9a-fA-F]{64}$/;

// rawBody: Buffer exactly as received. Never parse and re-serialize it first.
function verifyChameleonSignature(rawBody, signature, secret = process.env.CHAMELEON_WEBHOOK_SECRET) {
  if (!secret || typeof signature !== 'string' || !HEX_SHA256.test(signature)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest(); // key = UTF-8 secret
  return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), expected); // validated, so equal length
}

```

* Header: `X-Chameleon-Signature`, the **bare** lowercase hex digest (no `sha256=` prefix). Compare the
  whole value as one digest.
* Signed content is the **raw request body only**. No timestamp, id or other header is signed.
* The key is the account's **Webhook Secret** used as-is (UTF-8 bytes, not hex- or base64-decoded).
* Validate the header first (exactly 64 hex characters, either case), then compare in constant time.
  Python: `hmac.compare_digest` on bytes.
* Return `401` for a missing, malformed or mismatching signature. Read the body raw
  (`express.raw({ type: '*/*' })`) and parse JSON only after it verifies.
* Replay: nothing signed carries a time, so there is no replay window to enforce by default. Chameleon's
  Rails sample also checks the body's `sent_at` is within 5 minutes; the examples offer that as an
  opt-in check (`CHAMELEON_SENT_AT_TOLERANCE_SECONDS`, off by default) that runs after verification.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/chameleon-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/chameleon-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/chameleon-webhooks/examples/fastapi/).

## Event Types

The body is `{id, kind, sent_at, data}`. Dispatch on `kind`; no header names the topic. `data` holds
the included models (`profile`, `tour`, `survey`, `response`, `step`, ...). Chameleon also sends
`X-Chameleon-Id`, the same `id` as the body. Dedupe on it, because non-200 responses are retried.

| Event | Sent When |
| --- | --- |
| `ping` | Endpoint health check (verify it, nothing else to do) |
| `tour.started`, `tour.completed`, `tour.exited`, `tour.snoozed`, `tour.button.clicked` | A Tour starts, completes, is exited or snoozed, or a button is clicked |
| `survey.started`, `survey.completed`, `survey.exited`, `survey.snoozed`, `survey.button.clicked` | A Microsurvey starts, completes, is exited or snoozed, or a button is clicked |
| `response.finished` | A Microsurvey is finished (all Steps, including any comment) |
| `embed.started`, `embed.completed`, `embed.exited`, `embed.snoozed`, `embed.button.clicked` | The same lifecycle for Embeds |
| `demo.started`, `demo.reveal`, `demo.finished`, `demo.form.submitted`, `demo.email.added` | A Demo starts, identifies a company, finishes, has a form submitted or has an email added |
| `helpbar.search`, `helpbar.answer`, `helpbar.item.action`, `helpbar.item.error` | HelpBar results shown, AI answer generated, item actioned or an item action errors |
| `alert.triggered` | An Alert's conditions are violated |

## Environment Variables

```bash
CHAMELEON_WEBHOOK_SECRET=your_webhook_secret   # Integrations -> Webhooks page in Chameleon
# CHAMELEON_SENT_AT_TOLERANCE_SECONDS=300      # optional: reject a stale or future sent_at with 400

```

## Local Development

Chameleon can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

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

```

Use the printed URL as the endpoint URL in Chameleon.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/chameleon-webhooks/references/overview.md) - What Chameleon webhooks are, events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/chameleon-webhooks/references/setup.md) - Add the endpoint and find the Webhook Secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/chameleon-webhooks/references/verification.md) - Signature details, gotchas, debugging failures
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/chameleon-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests