TroubleshootingAll pages

Troubleshooting

The widget is built to fail quietly — it never throws into your page, and it warns at most once. That is right for your visitors and unhelpful for you, so here is what each silence actually means.

Four things to check first#

console, on the affected page
// Paste into the console on the page that is misbehaving.
window.__CONNECT_LOADED__;            // true if the loader ran
window.CONNECT_WEBSITE_KEY;           // your key, spelled correctly
location.origin;                      // must be in Allowed origins, exactly
$connect.push(["do", "chat:open"]);   // opens it, if it is merely hidden

Between them these separate the four common causes: the script never loaded, the key is wrong, the origin is not allowed, or the widget is loaded and simply hidden.

The widget is not there#

The console says “missing CONNECT_WEBSITE_KEY”#

The loader ran before window.CONNECT_WEBSITE_KEY was set. Both belong in the same script block, key first, exactly as the snippet has them. A tag manager that reorders or defers fragments is the usual culprit — put the whole snippet in one custom-HTML tag rather than splitting it.

Nothing in the console at all#

  • The script never loaded. Check the network tab for /widget/v1.js. A blocked request means a content-security policy, an ad blocker, or a wrong host in s.src.
  • A CSP violation is reported separately from the request — look for a Refused to load the script line and add your Connect host to script-src and frame-src.
  • __CONNECT_LOADED__ is true but nothing is visible: something called chat:hide. In a single-page app, a route guard that hides on entry and never shows again is the classic version of this.
  • Serving your page over http:// while Connect is on https:// gets the script blocked as mixed content. Both must be HTTPS outside local development.

403 origin not allowed#

The most common install failure. The launcher may still appear from cache, but the chat window stays empty because the boot request was refused.

Connect compares the origin of the page the widget is on against the website’s Allowed origins as an exact string. Walk the list:

  • Run location.origin in the console and copy the result. That exact string, character for character, has to be in the list.
  • No trailing slash, no path. https://acme.com/ will not match.
  • www. is a different origin. So is every other subdomain, and so is http versus https.
  • The port is part of it: http://localhost:5173, not http://localhost.
  • A brand-new website has an empty list and boots nowhere. This is the one that catches people during their first install.
  • Preview deployments with generated hostnames cannot be listed one by one. Use a stable alias domain for staging.

404 unknown website key#

  • A typo, or a key from a different workspace. Copy it again from the website’s dashboard page.
  • Someone rotated the key. Rotation invalidates the old one immediately and everywhere — every page carrying it stops booting until it is updated. If you embed the snippet in more than one place, update them all in the same change.

Messages are not live#

The widget holds an open server-sent-events stream, with a heartbeat every 15 seconds and a watchdog that reconnects if 45 seconds pass in silence. If messages only appear after a reload, that stream is being interfered with.

A proxy is buffering the stream#

The usual cause when self-hosting. A reverse proxy that buffers responses holds every event until its buffer fills, which looks exactly like “realtime is broken”.

nginx — the directives that matter
location / {
  proxy_pass http://connect_upstream;
  proxy_http_version 1.1;

  # Server-sent events must not be buffered or timed out.
  proxy_buffering off;
  proxy_cache off;
  proxy_read_timeout 1h;
}

On other proxies the equivalents are an X-Accel-Buffering: no response header, disabled response buffering, and a read timeout longer than an idle stream.

Token expiry is not a problem#

The visitor’s token lasts 15 minutes and is refreshed by quietly re-booting before it expires. An occasional re-boot in the network tab is the system working. A stream that closes and never returns is not — that means the session was revoked, which happens when the website key is rotated or the visitor is blocked.

A command did nothing#

Nothing in this API throws, so a wrong verb, a wrong key, or a command that does not exist all look identical: silence.

  • Check the spelling against the reference. chat:opened is an event; chat:open is an action. They are not interchangeable.
  • Check the argument nesting. session:data takes an array of pairs inside the argument array — three opening brackets, not two.
  • Your on handler may be waiting for an event that does not exist. Only chat:opened and chat:closed ever fire. See What does not exist.
  • An exception inside your own handler is caught and discarded. Wrap the body in try and log it yourself if a handler seems to half-run.
  • A value that fails validation is dropped server-side — an over-long custom-data value, a relative avatar URL, a 51st key. The widget carries on regardless.

It looks wrong#

The launcher flashes green, then changes
First visit with a cold cache. The launcher paints from the last known appearance before the widget boots; there is nothing cached yet, so it starts from the default. It happens once per browser.
A colour change has not appeared
Appearance is delivered at boot, so an open page keeps what it booted with. Reload.
No agent avatar on the launcher
Either nobody is online, or the image was blocked. Add https://gravatar.com and your team’s avatar host to img-src. A team member with no picture at all falls back to the icon by design.
The composer is behind the phone keyboard
Should not happen — the sheet tracks the visual viewport. If it does, the host page is likely transforming or scaling its body, which moves the fixed-position container out from under it.
Your CSS is affecting the widget
It cannot reach inside — the launcher is in a shadow root and the window is a cross-origin frame. What it can do is move them: a stacking context on an ancestor, or a global * { position: … }, will reposition their containers.

An upload was refused#

  • Accepted types are PNG, JPEG, WebP, GIF and PDF. Anything else is rejected on the server as well as filtered by the picker.
  • 10 MB per file.
  • One file at a time. Multi-select is not supported in the visitor composer.
  • On a self-hosted install, a refusal that mentions storage means the object-storage credentials or bucket are misconfigured — that is a server problem, not a widget one.

Rate limits#

Per visitor, or per website key and IP address for boot, in a rolling 60-second window. Over the limit returns 429 and the widget retries later rather than breaking. Normal use never approaches these.

Boot30 / minute
Per website key and IP.
Sending a message60 / minute
Per visitor.
Typing indicator30 / minute
Per visitor.
Uploads30 / minute
Per visitor.
Help search60 / minute
The input is debounced.
Reading an article30 / minute
Per visitor.
Suggested articles20 / minute
Fetched once a session.
Satisfaction rating10 / minute
One rating per conversation.
First-visit nudge5 / minute
Once per visitor, ever.

Hard limits#

Message text8000 characters
Per message.
Custom data50 keys · 60-char keys · 500-char values
Scalars only.
Segments20 · 60 chars each
Replaced, not merged, on every call.
Event name120 characters
Colour label up to 24.
Nickname120 characters
Longer names are rejected.
Registered functions32 per visitor
Extras are dropped.
Function result16 KB
Return a summary, not a dataset.
Function handler10 seconds
Then recorded as a timeout.
Uploads10 MB
PNG, JPEG, WebP, GIF, PDF.
Welcome text280 characters
Team name up to 120.
Automatic messages1000 characters
Greeting and nudge alike.
Allowed origins50 per website
Exact origins, no wildcards.

Still stuck#

Send the page URL, the website key, and whatever the console and network tab show to hello@usenormalize.com. The boot response in the network tab answers most questions on its own.