> ## Documentation Index
> Fetch the complete documentation index at: https://notifizz.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifizz Troubleshooting Guide

> Cross-cutting failure modes — duplicate events, signature failures, campaigns not firing, auth mismatches, region misconfiguration. Symptom → cause → fix.

# Troubleshooting

The cross-cutting "something's broken, where do I look?" guide. Each entry is a symptom, the most likely causes, and the fix.

This page is itself the FAQ — so it skips the per-page accordion convention and lists everything inline.

## TL;DR — first checks

* **Logs first.** Your application logs catch SDK errors before anything else.
* **Delivery History second.** The dashboard's per-event view shows whether Notifizz received the event and what it did.
* **Workflow trace third.** Each instance's trace shows step-by-step what ran.
* **Cross-environment contamination is the #1 cause of "weird".** Check the SDK secret and Front API Key match the environment you think you're hitting.

***

## Event tracking

<AccordionGroup>
  <Accordion title="`401 auth/tracking-secret-key` on `client.track()`">
    The SDK secret you passed doesn't resolve to an environment. Three suspects:

    1. **Wrong environment** — you used the dev secret in prod or vice versa. Most common.
    2. **Typo or whitespace** — copy-pasted with extra characters. Re-copy from the dashboard.
    3. **Recently rotated** — your service hasn't reloaded the env var.

    Check: the `Authorization: Bearer ...` header AND the `sdkSecretKey` body field both contain the same value, and that value is the current secret for your target environment.
  </Accordion>

  <Accordion title="`400 event/invalid-name`">
    The event name failed the grammar. Body's `reason` field has the specific violation. Most common: uppercase letters, special characters, or starting with a digit. Use lowercase + dots + underscores: `domain.event_name`. See [events](/docs/concepts/events).
  </Accordion>

  <Accordion title="`{ duplicate: true }` after a retry">
    Not an error — it's the success path for an idempotent retry. The backend has already processed this event under the same `idempotencyKey`. Your code does nothing.
  </Accordion>

  <Accordion title="`track()` succeeded but no campaign ran">
    The event reached Notifizz but no campaign matched. Three suspects:

    1. **Campaign status** — `Editing` only sometimes fires; `Archived` never does. Promote to `Dev` (dev) or `Live` (everywhere).
    2. **Event name mismatch** — case-sensitive. `Order.Shipped` ≠ `order.shipped`.
    3. **Environment mismatch** — campaign is `Live` on prod but you're firing from a dev SDK secret. The campaign exists in both views; the runtime gate is environment-scoped.

    Check Delivery History → filter by event name → if zero entries, no campaign listened.
  </Accordion>

  <Accordion title="Same event arrives twice — why isn't it deduped?">
    Idempotency keys are caller-supplied. If you let the SDK auto-generate (omitted `idempotencyKey`), each call gets a new UUID and dedup doesn't trigger. For retried emits, set a deterministic key derived from your domain (`order-shipped:${orderId}`).
  </Accordion>
</AccordionGroup>

***

## Campaigns

<AccordionGroup>
  <Accordion title="Campaign worked in Dev, fails on Live">
    Almost always: prod is missing a dependency dev had.

    * **Enricher URL** — set per environment. Prod has its own dashboard config; copy from dev or set fresh.
    * **Connector source** — Stripe / Segment / etc. sources are per-environment. Wire prod separately.
    * **Variables** — organisation variables and environment variables differ. Compare values in the dashboard side-by-side.

    Less common: prod traffic has a property shape your dev test events didn't reproduce. The workflow trace pinpoints the missing field.
  </Accordion>

  <Accordion title="Campaign edits not taking effect">
    Did you save and promote? Status flow: `Editing → Dev → Live`. Just saving keeps it at `Editing`. The dashboard shows the current status prominently in the campaign header.

    If the status is correct but a previous version is still firing on in-flight events: that's by design. In-flight workflow instances run against the version they started on; new traffic picks up the new version.
  </Accordion>

  <Accordion title="Two campaigns on the same event — only one runs">
    Possible but unusual. Each match creates an independent workflow instance — they don't gate each other. If you only see one running, check both campaigns:

    * **Status** — both must be runnable for the current environment.
    * **Event binding** — both must explicitly bind to the same event id, not just the same name.
    * **Filtering in the orchestrator** — if both orchestrators have the same `if (!matches) return []`, both can output empty.
  </Accordion>
</AccordionGroup>

***

## Widget

<AccordionGroup>
  <Accordion title="`isReady` never becomes `true`">
    Authentication failed. Three layers to check:

    1. **Browser console** — the widget logs auth errors with detail.
    2. **`state.errorCode`** — typed error from the widget. `auth/invalid-token-signature` means the JWT didn't verify; `auth/no-public-key-registered` means the env doesn't have a JWKS configured.
    3. **`state.hasError === true`** — fires on any auth failure path.

    Common causes: stale token, wrong `authType`, expired Front API Key, env mismatch (using a dev `apiKey` against a prod-deployed widget).
  </Accordion>

  <Accordion title="Bell renders, badge stays at 0">
    Auth succeeded but no message reached this user. Three checks:

    1. **`userId` matches** the recipient identifier the campaign emits. String comparison; no implicit coercion.
    2. **Campaign actually fired** — Delivery History → filter by user.
    3. **First-load state** — a fresh user has no inbox record; the widget creates it lazily. Fire one event before opening the widget for the first time.
  </Accordion>

  <Accordion title="Vite hooks crash with `useState is null`">
    Two copies of React in the bundle. The Notifizz widget script and your app are loading two distinct React copies. The fix is in your bundler config — alias `react` to a single instance:

    ```js theme={null}
    // vite.config.js
    resolve: {
      dedupe: ['react', 'react-dom'],
    },
    ```

    See the React SDK FAQ for the React-specific variant of this issue.
  </Accordion>

  <Accordion title="Widget mounted twice in dev mode (React StrictMode)">
    React 18+ runs effects twice in dev intentionally. The widget guards against duplicate mounts via `cancelled` flags and a `readyFired` ref — you should not see two bells, but you may see two ready callbacks fire briefly. Production builds run effects once.
  </Accordion>

  <Accordion title="Multiple users on the same browser — previous user's notifications visible">
    `destroy()` was not called on logout. Without it, the widget caches real-time state per session. Always destroy + re-mount on identity change.
  </Accordion>
</AccordionGroup>

***

## Auth modes

<AccordionGroup>
  <Accordion title="`backendToken` mode — widget shows `state.hasError = true`">
    Most common: the `userId` you passed to the widget doesn't match the `userId` you passed to `client.generateHashedToken`. Even a string-vs-number difference breaks the hash. Compare both sources character-by-character.
  </Accordion>

  <Accordion title="Firebase token expires after 1 hour, widget breaks">
    Firebase ID tokens expire. Re-mount the widget on `onIdTokenChanged`:

    ```ts theme={null}
    onIdTokenChanged(getAuth(), async (user) => {
      if (user) setNotifizzToken(await user.getIdToken());
    });
    ```

    The widget surfaces `state.hasError = true` on expiry; that's your re-mount cue if you don't subscribe to token changes.
  </Accordion>

  <Accordion title="`PubliclySignedJwt` — `kid not found`">
    The JWT's `kid` header references a key not in your JWKS endpoint's response. Three causes:

    1. **Wrong JWKS URL** in the dashboard.
    2. **IDP rotated keys** and the cached JWKS is stale (Notifizz refreshes every few minutes).
    3. **Cross-environment** — the JWT was issued by a different IDP environment than the one whose JWKS you configured.
  </Accordion>
</AccordionGroup>

***

## Connector webhooks

<AccordionGroup>
  <Accordion title="`401` on every Segment / Stripe / HubSpot / Intercom delivery">
    The signature didn't verify. The most common cause: the `verificationSecret` in the dashboard doesn't match the one configured on the provider. Re-copy from the provider; some providers regenerate the displayed secret each time you click "show".
  </Accordion>

  <Accordion title="Stripe signature passes locally but fails in production">
    Clock drift. Stripe signatures include a timestamp; the verifier rejects requests outside its tolerance. NTP your Notifizz region (rare — Stripe's tolerance is generous). Less commonly, the prod environment uses a different Stripe webhook than dev — re-copy the secret per environment.
  </Accordion>

  <Accordion title="Provider sent a test event but no campaign fired">
    Two layers to check:

    1. **Verified?** Delivery History → filter by source. A failed signature shows up as a 401 entry.
    2. **Mapped?** The connector's event-mapper translates provider event types into Notifizz events. Check the dashboard's mapping UI for unmapped types — campaigns can't fire on an event that hasn't been named.
  </Accordion>
</AccordionGroup>

***

## Enrichers

<AccordionGroup>
  <Accordion title="`hmac-invalid`">
    Two causes (in order of frequency):

    1. **`webhookSigningSecret` mismatch** between dashboard and constructor.
    2. **The framework re-serialised the body before forwarding** — `dispatch()` signs and verifies the verbatim `payload` **string** inside the envelope, so re-stringifying the parsed inner object would change the bytes. Forward `req.body` as-is. Modern body parsers (Express `express.json()`, Fastify, NestJS, Hono) preserve the envelope as JSON and never touch the inner `payload` string. If you wrote a custom pre-processing layer, drop it.
  </Accordion>

  <Accordion title="`stale-timestamp`">
    Clock drift > 5 minutes. NTP your enricher host. Containers with broken time sync are a common cause.
  </Accordion>

  <Accordion title="Enricher called every time, despite `cache: { ttl: '1h' }`">
    The cache key is `(orgId, enricherName, paramsHash)`. If your campaign passes per-call data into the enricher params (timestamp, random id), every call is a miss. Check what the orchestrator actually passes — usually you want stable IDs (userId, orderId), not request-time data.
  </Accordion>

  <Accordion title="Enricher returns expected data but campaign uses old values">
    The cache served a stale entry. Your TTL is too long for the data freshness this campaign needs. Lower the TTL or set `cache: false` for this enricher.
  </Accordion>
</AccordionGroup>

***

## Region

<AccordionGroup>
  <Accordion title="Wrong API host — getting 401 / 404 across the board">
    The default is `https://eu.api.notifizz.com/v1`. If your environment is in a different region, the SDK must hit the correct hostname. Override via `client.config({ baseUrl: "..." })` or in the constructor. Check the dashboard environment settings for the correct hostname.
  </Accordion>
</AccordionGroup>

***

## See also

<CardGroup cols={2}>
  <Card title="Observability" icon="magnifying-glass-chart" href="/docs/operations/observability">
    Correlation IDs, workflow traces, AI usage.
  </Card>

  <Card title="Error catalogue" icon="circle-exclamation" href="/docs/sdks/error-catalogue">
    Every typed error code.
  </Card>

  <Card title="Delivery history & stats" icon="chart-line" href="/docs/operations/delivery-history-and-stats">
    Aggregated view per campaign.
  </Card>

  <Card title="Keys and environments" icon="key" href="/docs/environments/api-keys">
    Where each secret comes from.
  </Card>
</CardGroup>
