Everything works except the QR code
The report arrives with no useful detail, because there is none to give. Someone opens Buzz on their desktop, picks the option to add a phone, and a QR code appears immediately. They scan it. A few seconds later the desktop raises a pairing error, and the text of it carries a 404.
Which is confusing, because the relay is plainly healthy. Messages arrive, files upload, invites work, the desktop that is failing to pair is itself connected to the relay right now. Only pairing is broken, and the reason is that pairing is the one thing that does not talk to the relay you configured.
An unpaired phone cannot knock on the front door
A relay that enforces membership gates the WebSocket connection itself: you prove who you are before you get a socket. That is the whole point of turning it on. But a phone that has never been paired holds no identity the relay recognises. It cannot connect to the main relay to receive the pairing handshake, because the handshake is how it gets the key it would need to connect. Chicken, egg.
So the pairing handshake runs somewhere else, over a channel that accepts anybody: a tiny relay that carries one event kind, keeps no history, and forgets you after two minutes. On an open relay with no membership enforcement none of this is needed, because the phone can just connect to the main relay like any other stranger. The out-of-band channel exists precisely because you locked the front door.
The channel is a second process, and it hides in plain sight
That channel is buzz-pair-relay, a separate binary in the Buzz tree. It is deliberately small: no persistence, no auth, no history, one event kind, signature-checked, deduplicated, with a freshness window, a cap of 128 connections, a 4 KiB frame limit and a hard 120 second lifetime per connection. It is the kind of component you can read in one sitting and then stop thinking about.
Its deployment note is the entire story of this article. It binds loopback only, default 127.0.0.1:5000, and it is documented as requiring a reverse proxy in front of it that routes /pair to it, terminates TLS and enforces read timeouts. It does not restrict paths or limit pre-upgrade connections itself, on purpose, because those are the proxy's job. A process listening on 127.0.0.1 that nothing routes to is a process that does nothing at all, quietly, forever.
And here is the part that makes it slippery: the binary ships inside the standard relay image. The Dockerfile builds it alongside the relay and copies it to /usr/local/bin/buzz-pair-relay. So if you go looking, it is right there in the container. It looks installed because it is installed. It just was never started, and nothing was ever routed to it.
How the client decides where to pair
Before the desktop draws a QR code it asks the relay where pairing happens. The question is a plain HTTP GET to the relay's origin with Accept: application/nostr+json, the standard NIP-11 information document, and the answer decides everything that follows.
There are three outcomes. If the document carries a pairing_relay_url that parses as a ws or wss URL, the desktop uses that and nothing else matters. Otherwise, if supported_nips contains 43, the relay is saying it enforces membership, and the desktop falls back to a legacy convention: take the relay's own URL and append /pair. Failing both, it uses the main relay directly, which is the right answer for an open relay.
The second branch is the trap. It is chosen from an advertisement, not from a probe. Nothing checks that /pair exists before the QR code goes on screen, because the QR code is built from the URL rather than from a successful connection to it. So a relay that enforces membership, and therefore honestly advertises NIP-43, points every pairing attempt at a path that may be a 404, and the customer is the one who finds out.
One request, and you know
You do not need a phone, a QR code or a second device to check this. Ask the relay's public hostname for /pair with curl, from anywhere.
A 404 means pairing is broken on that relay, today, for everyone. The answer worth knowing about is the healthy one, because it looks like a failure too: a correctly routed sidecar replies 400 Bad Request. Curl does not send a WebSocket upgrade, and the sidecar rejects anything that is not a well-formed upgrade with a 400 before it does anything else. So 400 is good news and 404 is bad news, and the two are one digit apart in a place where nobody is looking carefully.
Then read what the relay is claiming, because that decides whether /pair is even the right thing to test. If pairing_relay_url is set, clients go there and the same 400-versus-404 test applies to that host instead. If it is absent and NIP-43 is in the list, the legacy path is live and /pair is exactly where every client will arrive.
Si pairing_relay_url renvoie une valeur, c'est cette URL qu'il faut tester, et /pair n'est pas utilisé du tout. Si elle est nulle et que la vérification NIP-43 renvoie un indice plutôt que null, alors /pair sur cet hôte est exactement là où ira chaque client.
Three fixes, and you only need one
Start it and route to it. The binary is already in your image, so this is a second service and a proxy rule, not a rebuild: run buzz-pair-relay alongside the relay, then add a /pair route in front that forwards the WebSocket upgrade to it on port 5000. Route only /pair, and keep TLS and the read timeouts at the proxy, which is what the sidecar is written to assume.
Or give it its own hostname. Set BUZZ_PAIRING_RELAY_URL on the relay and it advertises pairing_relay_url in its NIP-11 document, so clients skip the legacy convention entirely and connect straight to a dedicated endpoint. On the Helm chart that is pairingRelay.enabled and pairingRelay.url, which run the sidecar as its own Deployment and Service on port 5000. The chart does not create an ingress for it, deliberately, so routing that hostname is still your job.
Or stop advertising what you are not doing. If your relay does not actually enforce membership, it should not be advertising NIP-43 at all, and then the client never takes the legacy branch. Upstream keeps NIP-43 out of the static list of supported NIPs for exactly this reason and pins the decision with a test whose comment says, in as many words, that advertising it on open relays misroutes pairing peers to a non-existent /pair sidecar.
We shipped this gap too
Our own managed tenants ran the relay image without ever starting the sidecar, and the binary was sitting in the container the entire time. We found it the way everybody finds it: by scanning a QR code. Three things conspire to hide this one, and none of them is anybody's carelessness. The sidecar binds to loopback, so it never fails loudly. It ships inside the image, so it looks present. And the client picks the legacy path from a declaration rather than a check, so the QR code renders perfectly right up until the connection is attempted. That is a failure mode worth checking for rather than assuming you are clear of: if you run a Buzz relay that enforces membership, send the one request at the top of this page before somebody scans a code.