Skip to main content

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. 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:
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.
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.

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:
Then, in the configuration of your development profile only:
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.
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.
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.

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.
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.

Timing

The bridge is a development tool and its patience reflects that. 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.
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, 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.

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

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. 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.
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.
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.
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.
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.
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.

See also

Enrichers tutorial

Register an enricher, mount the dispatch route, watch the orchestrator call it.

Enrichers protocol

Envelope shape, HMAC, error codes — what the bridge carries unchanged.

Environments

Why every developer should own a non-production environment.

Node.js SDK

The client that exposes startDevTunnel and dispatch.