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#
// 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 hiddenBetween 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 ins.src. - A CSP violation is reported separately from the request — look for a
Refused to load the scriptline and add your Connect host to script-src and frame-src. __CONNECT_LOADED__istruebut nothing is visible: something calledchat: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 onhttps://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.originin 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 ishttpversushttps.- The port is part of it:
http://localhost:5173, nothttp://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”.
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:openedis an event;chat:openis an action. They are not interchangeable. - Check the argument nesting.
session:datatakes an array of pairs inside the argument array — three opening brackets, not two. - Your
onhandler may be waiting for an event that does not exist. Onlychat:openedandchat:closedever fire. See What does not exist. - An exception inside your own handler is caught and discarded. Wrap the body in
tryand 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.comand your team’s avatar host toimg-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.