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

# Local Development Tunnel — Let Notifizz Reach Your Machine

> Run enrichers on your laptop and let Notifizz call them: the first-party dev bridge for Node and Java, the standalone command for other stacks, ngrok and cloudflared as fallbacks.

# Local development tunnel

**Written for Dev.** Product and Ops get one takeaway from this page: a developer building an enricher does **not** need a public URL, a firewall exception, or a URL to re-approve every morning. Everything below is dev-only — nothing here ever runs in production.

## TL;DR

* Notifizz calls your app **inbound** for two things: **discovery** (what enrichers and events do you expose?) and **enricher execution** at notification time. Neither can reach `localhost`.
* The first-party fix is a **dev bridge that your app opens itself**: `startDevTunnel()` in the Node SDK, the `notifizz-dev` module in Java/Spring. Start the app, the bridge is live. Nothing to launch by hand.
* It **authenticates with the environment secrets your client already holds** — the SDK secret key and the signing secret. Nothing to paste in the dashboard, nothing to re-approve.
* **It does not register a public webhook for you.** It registers a transport-only entry under the name you choose, and only on a **non-production** environment. Your deployed service still gets a real URL, registered the normal way.
* `track()` is **outbound** and needs no bridge at all.
* ngrok and cloudflared still work, and are the honest answer for a stack with no in-process module.

## Why a tunnel exists at all

Traffic between your app and Notifizz goes both ways, and only one direction is a problem locally.

| Direction           | What travels                                                                                        | Works from localhost?                          |
| ------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| Your app → Notifizz | `track()`, event declarations, the boot ping                                                        | **Yes.** Ordinary outbound HTTPS.              |
| Notifizz → your app | Discovery of your enrichers and events, then enricher execution while a notification is being built | **No.** Notifizz has no route to your machine. |

In production the second row is solved by a public hostname: you deploy, you register `https://api.example.com/notifizz`, discovery calls it. On your laptop there is nothing to register — and you do not want Notifizz holding an address that points at your machine anyway.

The dev bridge inverts the call. Your app opens the connection outbound and keeps it open; Notifizz hands it the same signed envelope it would have POSTed to a public URL, and your app answers over the connection it already owns. The contract is identical — same envelope, same HMAC, same `dispatch()`. Only the transport differs.

## The first-party bridge

### Node

`@notifizz/nodejs` exports `startDevTunnel`. Call it once, after your server is listening, behind a guard:

```ts theme={null}
import { startDevTunnel } from "@notifizz/nodejs";
import { notifizz } from "./notifizz.config";

app.listen(3000, () => {
  notifizz.ready();

  if (process.env.NODE_ENV !== "production") {
    const tunnel = startDevTunnel({
      apiBaseUrl: "https://api.notifizz.com/v1",
      sdkSecretKey: process.env.NOTIFIZZ_SDK_SECRET_KEY!,
      webhookSigningSecret: process.env.NOTIFIZZ_WEBHOOK_SIGNING_SECRET!,
      endpointName: "orders-api",
      dispatch: (body) => notifizz.dispatch(body),
    });

    process.on("SIGTERM", () => tunnel.stop());
  }
});
```

| Option                 | Required | Meaning                                                                                                       |
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `apiBaseUrl`           | yes      | The Notifizz API root your environment lives on.                                                              |
| `sdkSecretKey`         | yes      | The environment's SDK secret key — this is what identifies the environment.                                   |
| `webhookSigningSecret` | yes      | The environment's signing secret — used to sign each answer.                                                  |
| `endpointName`         | yes      | A logical name for **this** service. It becomes the entry Notifizz shows, and the unit health is reported on. |
| `dispatch`             | yes      | `(body) => notifizz.dispatch(body)` — the same call your HTTP route makes.                                    |
| `log`                  | no       | Custom logger. Defaults to `console`.                                                                         |
| `idleMs`               | no       | Floor between two polls, in ms. Default `250`.                                                                |

It returns a handle with a single method, `stop()`. The function never throws and never blocks your boot: if Notifizz is unreachable it logs and retries.

<Note>
  `dispatch` is a function you pass, not a route Notifizz calls. The bridge hands the envelope to your client **in the same process** — there is no local HTTP hop, so a dispatch route that is behind your app's own authentication cannot block it.
</Note>

### Java / Spring Boot

Java gets the same bridge as an auto-configured module. Add it in a **dev or test scope** so it can never reach a production classpath:

```xml theme={null}
<dependency>
    <groupId>com.notifizz</groupId>
    <artifactId>notifizz-dev</artifactId>
    <version>0.1.0</version>
    <scope>test</scope>
</dependency>
```

Then, in the configuration of your **development profile only**:

```properties theme={null}
notifizz.base-url=https://api.notifizz.com/v1
notifizz.sdk-secret-key=${NOTIFIZZ_SDK_SECRET_KEY}
notifizz.webhook-signing-secret=${NOTIFIZZ_WEBHOOK_SIGNING_SECRET}

notifizz.dev.enabled=true
notifizz.dev.endpoint-name=orders-api
```

The auto-configuration activates on two conditions, both of which must hold: `notifizz.dev.enabled=true`, and a `NotifizzClient` bean present in the context. It opens the bridge when the application reports ready, and closes it when the context shuts down. `notifizz.dev.endpoint-name` defaults to `app` — name it after the service if you run more than one.

Two guards, deliberately, rather than one: the scope keeps the module out of your production artifact, and the flag keeps it off even if the scope is ever loosened.

### Other stacks — the standalone command

PHP, Go, Rails, or any service that has no in-process module yet: `@notifizz/dev` is a small published command that does the same job from outside the process. It needs Node on the machine, but not in your app.

```bash theme={null}
NOTIFIZZ_API_URL=https://api.notifizz.com \
NOTIFIZZ_API_KEY=<your API key> \
NOTIFIZZ_DISCOVERY_URL=http://localhost:8080/internal/notifizz \
npx @notifizz/dev tunnel
```

| Variable                  | Meaning                                                                                                                                                                      |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NOTIFIZZ_API_URL`        | The Notifizz API root. Defaults to `https://api.notifizz.com`.                                                                                                               |
| `NOTIFIZZ_API_KEY`        | An API key generated in the dashboard under the AI settings. The organisation is derived from it server-side.                                                                |
| `NOTIFIZZ_DISCOVERY_URL`  | The **exact** path your dispatch route is mounted on — it is often namespaced, so check it rather than assuming `/notifizz`.                                                 |
| `NOTIFIZZ_DISCOVERY_URLS` | Several services at once: `orders-api=http://localhost:8080/internal/notifizz,billing-api=http://localhost:8090/internal/notifizz`. Takes precedence over the singular form. |
| `NOTIFIZZ_DEBUG=1`        | Prints the raw body your local service answered with. The fastest way to see an empty or non-JSON response.                                                                  |

Unlike the in-process bridge, this one does make a local HTTP call to your dispatch route — so that route must be reachable without your app's own session or JWT auth. It is authenticated by the HMAC in the body, not by a login. The command also **refuses to forward anywhere but loopback**: even if something asked it to, it will not call an address on your network.

<Warning>
  **Limited availability.** The standalone command needs the address of the real-time endpoint for your Notifizz instance in addition to the variables above, and that address is not self-serve today. Ask your Notifizz contact before building a workflow on it. If you cannot get it, use the in-process bridge where your language has one, and a public tunnel otherwise.
</Warning>

## What the bridge does — and what it does not

This is the part worth reading twice, because the failure mode of getting it wrong is silent.

**It does:**

* **Register itself as a transport-only entry** on the environment, named after your `endpointName`. That is why you never paste a URL, and why the dashboard can report health **per service** rather than one opaque green dot. The entry carries a name, never an address.
* **Carry both directions of the inbound contract** — discovery and enricher execution. An orchestrator building a notification against your dev environment really does call the enricher running on your laptop.
* **Refuse production.** The bridge resolves an environment from your SDK secret key and rejects it outright if that environment is a production one. There is no flag to override this.
* **Expire on its own.** The bridge announces itself roughly every minute. Stop your app and the announcement lapses within about a minute and a half; discovery then reports the endpoint unreachable instead of hanging.

**It does not:**

* **Register a public webhook URL.** Nothing about the bridge survives your local session as a callable address. When you deploy the same service, you register its real public URL — by hand in environment settings, or by letting the SDK announce it for one-click approval. Handing a private or loopback URL to Notifizz registers nothing at all: private addresses are refused, and the bridge is what covers local discovery instead.
* **Replace your dispatch route.** Keep the route mounted. Production uses it, and it is what a public tunnel would target.
* **Touch `track()`.** Event tracking is an outbound HTTPS call from your app. It works with the bridge down, and a tracking problem is never a bridge problem.
* **Expose your machine.** No inbound port is opened, and nothing publishes an address for your host.

<Note>
  If an entry with the same name already exists on the environment pointing at a real URL, the existing one wins and the bridge entry is **not** created — the collision is intentional, so a local session can never quietly take over a configured endpoint. Give the bridge a different `endpointName`.
</Note>

## Timing

The bridge is a development tool and its patience reflects that.

| Call               | Ceiling |
| ------------------ | ------- |
| Discovery probe    | \~6 s   |
| Enricher execution | \~11 s  |

An enricher whose handler is slower than that will time out over the bridge while being perfectly fine in production. If you are debugging with a breakpoint inside a handler, expect the call to fail — that is the timeout, not your code.

## Falling back to a public tunnel

Nothing stops you from exposing a real URL, and for some setups it is simpler — a stack with no in-process module, a colleague who needs to hit the same endpoint, or a proxy you want in the path.

<CodeGroup>
  ```bash ngrok theme={null}
  ngrok http 3000
  # Take the https URL and append your dispatch path, e.g. /notifizz
  ```

  ```bash cloudflared theme={null}
  cloudflared tunnel --url http://localhost:3000
  # Take the trycloudflare.com URL and append your dispatch path
  ```
</CodeGroup>

Then register that URL as a normal endpoint in environment settings and run discovery, exactly as you would for a deployed service — see the [enrichers tutorial](/docs/sdks/how-to/enrichers-tutorial), step 4.

Two costs to accept, and they are why the first-party bridge exists: the URL changes every time the tunnel restarts, so you re-register and re-approve it; and for as long as it is up, a public address routes into your machine.

## One environment per developer

The bridge is keyed by environment and endpoint name, and only one process serves a given pair at a time. Two developers pointing their laptops at the same dev environment with the same endpoint name will take turns answering each other's discovery probes, and each will see the other's catalogue.

Give every developer their **own non-production environment** with their own keys. It makes the whole class of "it works on my machine, and apparently on my colleague's too" disappear. See [environments](/docs/environments/overview).

## Going to production

Nothing to undo, if you wired it as shown:

1. The `NODE_ENV` guard (or the dev-scoped dependency plus `notifizz.dev.enabled`) keeps the bridge out of the deployed process.
2. Deploy the service with its dispatch route mounted at the same path.
3. Register the public URL on the production environment, or let the SDK announce it and approve it once.
4. Run discovery against production. It uses ordinary HTTPS; the bridge is not involved and would be refused if it tried.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Discovery says no responder, but my app is running.">
    Read this one as a **transport** verdict, not a verdict on your code. It means the probe never came back through the bridge — the app is not started, the bridge is not wired, or the guard that starts it is false in the profile actually running.

    Before you change a single line of application code, prove the app in isolation: build a discovery envelope, sign the **raw body** with HMAC-SHA256 using that environment's signing secret, and POST it straight at your dispatch route. In Node, `signDispatchPayload` does the signing for you — see the [protocol reference](/docs/sdks/event-tracking/enrichers-protocol#local-development). If that returns your catalogue, the route, the HMAC and the wiring are all correct and the only broken link is the bridge. An unsigned `curl` returning 200 proves nothing: discovery is rejected without a valid signature.
  </Accordion>

  <Accordion title="The service answers, but the catalogue is empty.">
    Discovery reached your app and your app said it exposes nothing. Three usual causes, in order of frequency: the integration is behind a feature flag that is off in this profile; enrichers are registered **after** the route starts serving, so the first probe sees none; or the route returns nothing instead of returning the result of `dispatch()`.

    Set `NOTIFIZZ_DEBUG=1` on the standalone command to print the raw body your service answered with — it separates "answered with an empty catalogue" from "answered with nothing at all" in one line.
  </Accordion>

  <Accordion title="My Live campaign went Offline while I was working locally.">
    An enricher that disappears from a discovery pass is marked **removed**, and any campaign referencing it is demoted from `Live` to `Offline` so nobody finds out by way of a broken send. Campaign statuses are organisation-wide, so a dev environment that answers with an empty catalogue can trigger this.

    An endpoint that is merely **unreachable** — bridge stopped, app down — is treated as transient and demotes nothing. That distinction is the reason the previous accordion matters: an app that answers `{ enrichers: [] }` is far more consequential than an app that does not answer at all.
  </Accordion>

  <Accordion title="An enricher fails with dev tunnel inactive.">
    The endpoint is registered as a bridge entry, but no process is currently serving it — you stopped the app, or restarted it without the guard being true. Notifizz returns a clear failure instead of attempting a call it cannot make. Start the app with the bridge enabled and retry.
  </Accordion>

  <Accordion title="Signature errors on every probe.">
    The signing secret your app verifies with is not the one belonging to the environment the bridge resolved. The classic cause is a `.env` holding keys from a **different** environment — the SDK key selects the environment, the signing secret must match that same one. Re-read both from the same environment's settings.
  </Accordion>

  <Accordion title="Should I run the bridge in CI?">
    No. CI has no enrichers worth discovering, and a bridge that opens from a build agent points discovery at an ephemeral machine. Test `dispatch()` directly instead: all three backend SDKs can sign an envelope, so you can call `dispatch()` in-process with no HTTP server and no network.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Enrichers tutorial" icon="puzzle-piece" href="/docs/sdks/how-to/enrichers-tutorial">
    Register an enricher, mount the dispatch route, watch the orchestrator call it.
  </Card>

  <Card title="Enrichers protocol" icon="file-signature" href="/docs/sdks/event-tracking/enrichers-protocol">
    Envelope shape, HMAC, error codes — what the bridge carries unchanged.
  </Card>

  <Card title="Environments" icon="layer-group" href="/docs/environments/overview">
    Why every developer should own a non-production environment.
  </Card>

  <Card title="Node.js SDK" icon="node-js" href="/docs/sdks/event-tracking/node-js">
    The client that exposes startDevTunnel and dispatch.
  </Card>
</CardGroup>
