Integration guide
One script tag puts your agent on any website. Where it appears, a floating bubble, a docked side panel, or a block inside your own layout is a setting, not a different integration.
Launcher in a corner that opens a small chat window over the page. The default.
Full-height drawer against one edge, with the page dimmed behind it.
Sits inside your layout, always open, with no launcher.
From the dashboard → open your agent, go to Widget Script, pick a placement and save. Every site running the snippet picks it up on the next page load, with no code change. Prefer this.
From the snippet → pass options to initRetentifi. These win over the saved settings, which is how one agent can be a bubble on your marketing pages and inline on a support page.
Precedence runs built-in defaults, then the agent's saved configuration, then the snippet. Anything you leave out falls through to the layer beneath.
A full-height drawer docked to the left or right edge. It overlays the page rather than pushing it, so nothing in your layout has to move.
Placement → Side panel, then choose the dock side and panel width. Paste this before your closing </body> tag on every page the agent should appear.
<!-- Begin Agent Embed Script --><script src="https://embed.retentifi.ai/chatbot-embed.js" defer></script><script>document.addEventListener('DOMContentLoaded', function () {window.initRetentifi('YOUR_AGENT_ID');});</script><!-- End Agent Embed Script -->
window.initRetentifi('YOUR_AGENT_ID', {mode: 'panel',side: 'right', // or 'left'width: 420 // pixels});
The agent renders inside an element you place, as part of the page. There is no launcher and nothing to open. Use it for a support page, a help centre, or a tab in your own interface.
Put this wherever the agent should appear.
<div id="retentifi-chat"></div>
<!-- Begin Agent Embed Script --><script src="https://embed.retentifi.ai/chatbot-embed.js" defer></script><script>document.addEventListener('DOMContentLoaded', function () {window.initRetentifi('YOUR_AGENT_ID');});</script><!-- End Agent Embed Script -->
With the placement set in the dashboard, that is all. To set it in code instead:
window.initRetentifi('YOUR_AGENT_ID', {mode: 'inline',target: '#retentifi-chat', // any CSS selectorheight: 610 // pixels});
The agent fills the width of its container, so give that container a width — a grid column, a max-width, a sidebar. Height comes from the height setting, not from the container.
Applies to the bubble and side panel. Inline is always open, so triggers do not apply to it.
Add data-retentifi-open to as many elements as you like. Clicks are handled by delegation, so elements your framework renders later work too, and links will not navigate.
<button data-retentifi-open>Talk to us</button><a href="#" data-retentifi-open>Need help?</a>
To remove the floating button so your own element is the only way in, set Opened by → Your element and give it a selector, or pass it in the snippet. Never leave the trigger empty: the launcher is kept instead, so visitors are not locked out.
window.initRetentifi('YOUR_AGENT_ID', {mode: 'panel',launcher: false,trigger: '[data-retentifi-open]' // or '#chat-open', '.nav-help'});
Every option is optional. Anything you omit comes from the agent's saved configuration, and anything unset there falls back to the default.
| Option | Applies to | Values | Default |
|---|---|---|---|
| mode | All | bubble · panel · inline | bubble |
| position | Bubble | bottom-right · bottom-left · center-right | bottom-right |
| side | Side panel | right · left | right |
| width | Side panel | Number, pixels | 420 |
| target | Inline | CSS selector | #retentifi-chat |
| height | Inline | Number, pixels | 610 |
| launcher | Bubble, side panel | Boolean | true |
| trigger | When launcher is false | CSS selector | — |
initRetentifi returns a handle to the agent it created, and the same controls sit on window.Retentifi. Call destroy() when a single-page app unmounts the component that created it, or you leave an orphaned frame behind.
const agent = window.initRetentifi('YOUR_AGENT_ID', { mode: 'panel' });agent.open(); // show itagent.close(); // hide itagent.toggle(); // flip, or toggle(true) / toggle(false)agent.getSession(); // current session, or nullagent.clearSession(); // forget the conversationagent.destroy(); // remove it from the pagewindow.Retentifi?.open?.(); // same controls, without the handle
On a marketing site the agent talks to strangers. Inside your own product it is talking to a customer you already know, so tell it who they are.
window.initRetentifi('YOUR_AGENT_ID', {user: {id: 'usr_948271', // required — YOUR id for this personemail: 'alex@company.com',name: 'Alex Rivera',role: 'Admin',companyId: 'org_acme_8819',companyName: 'Acme Corporation'}});
| Field | Type | What it does |
|---|---|---|
| id | String, required | Your own stable user id |
| String | Stops the agent asking for contact details | |
| name | String | Shown throughout the dashboard |
| role | String | Owner, Admin, Member |
| companyId | String | Groups several users into one account |
| companyName | String | Readable company name |
| hash | Hex string | Server-computed signature — see Verifying identity |
Emails change; ids do not. Without an id we fall back to a browser fingerprint, so the same person on a laptop and a phone becomes two customers with two separate histories.
The identity is recorded when the agent loads, whether or not anyone starts a conversation. That is the point: an account that is in your product every day but has not asked anything since June is invisible to a chat-only record, and is exactly the account that leaves without complaining.
Anonymous page loads are ignored entirely. With no id, nothing is written.
In most applications the user is not known when the agent first loads, and plans change under people mid-session. Identify whenever you know more, without re-initialising.
// after login, or after a plan changewindow.Retentifi?.identify(user, metadata);
Calling this before the agent has finished loading is safe, the identity is held and sent as soon as it is ready. Repeats are collapsed by user, page, metadata and day, so a busy app still counts as one visit.
What the account is worth, from whatever system holds it. This is what turns five unresolved conversations into two hundred and forty six thousand pounds of unresolved conversations.
window.initRetentifi('YOUR_AGENT_ID', {user: { /* ... */ },metadata: {plan_tier: 'Enterprise',currency: 'GBP',current_arr: 144000,renewal_date: '2026-10-24',seats_active: 110}});
| Key | Type | What it does |
|---|---|---|
| plan_tier | String | Shown on the account, and usable in routing rules |
| currency | ISO 4217 | Defaults to USD. Revenue is meaningless without it |
| billing_cycle | String | Monthly, Annual, and so on |
| current_mrr | Number | Monthly recurring revenue |
| current_arr | Number | Drives every revenue total, and revenue at risk |
| expected_value | Number | Contract value of a pilot, so it ranks with what it will become |
| renewal_date | ISO 8601 | Renewal urgency, and the 90-day view |
| trial_ends_at | ISO 8601 | The same, for trials and pilots |
| has_billing_issue | Boolean | Flags a failing card before it lapses quietly |
| seats_active | Number | Seat contraction usually precedes cancellation by months |
| account_owner_email | String | Who on your side owns the relationship |
Send whatever else you hold and we will keep and display it. Only the keys above can rank accounts, total revenue or drive a routing rule, because a field one business calls current_arr and another calls annual_value cannot be built on. Limits on the rest: 40 keys per payload, strings cut at 500 characters, nested objects and arrays dropped.
A page that knows the plan but not the renewal date will not blank a renewal date another page supplied. Send whatever that page happens to have.
An account with revenue is paying. An identified account without it has signed up but is not paying. No identity at all is a visitor. You never set this yourself.
Without a signature the page asserts who the user is, and anyone signed in to your application can edit the page. Signing the user id proves it, so one customer cannot claim to be a colleague.
// on your server, never in the browserconst hash = require('crypto').createHmac('sha256', process.env.RETENTIFI_SIGNING_KEY).update(user.id).digest('hex');
A key shipped to the page is readable by exactly the people the signature is meant to protect against. It leaves you with all of the work and none of the protection.
Settings, Identity, on your agent. It is shown once and masked from then on. We keep it to check signatures, not to hand back, so a lost key means rotating rather than recovering.
Unsigned identities are still accepted at this stage and simply recorded as unverified, so nothing breaks while your deploy rolls out. The dashboard lists which pages are still missing it.
Only once every user has arrived signed for 30 days. Enabling earlier locks the unsigned ones out of the agent, so we refuse it until they are clear.
A deploy reaches your servers one at a time, so cutting the previous key off the moment you rotate would reject every server that has not restarted yet.
One limit worth knowing: the signature covers the user id alone, so it does not expire. A hash captured from a screenshot or a log line keeps working until the key is rotated.
// app/layout.tsximport Script from 'next/script'// onReady, not onLoad: onLoad fires only the first time the script is// fetched, so navigating away and back would leave you with no agent.<Scriptsrc="https://embed.retentifi.ai/chatbot-embed.js"strategy="afterInteractive"onReady={() => window.initRetentifi('YOUR_AGENT_ID')}/>// for inline, put the container anywhere in your tree<div id="retentifi-chat" />// and any button that should open it<button data-retentifi-open>Talk to us</button>
Route changes are handled for you, the script watches pushState and popstate and keeps the agent informed of the current page. Calling initRetentifi twice with the same agent reuses the frame it already made, so React's double render in development is harmless.
Initialise once, identify whenever the user changes. Do not pass the user block to initRetentifi in a React application, authentication resolves after the first render, so the user is almost never known when the agent mounts.
In Next.js the signature belongs in a server component, where the key already lives, and the identity is handed to a small client component.
// app/layout.tsx — server component, where the key belongsimport { createHmac } from 'crypto'const user = await getUser()const identity = user && {id: user.id,email: user.email,name: user.name,companyId: user.orgId,companyName: user.orgName,hash: createHmac('sha256', process.env.RETENTIFI_SIGNING_KEY!).update(user.id).digest('hex')}<RetentifiWidget identity={identity} metadata={user?.billing} />
// app/retentifi.tsx'use client'export function RetentifiWidget({ identity, metadata }) {useEffect(() => {if (!identity?.id) returnwindow.Retentifi?.identify(identity, metadata)}, [identity, metadata])return (<Scriptsrc="https://embed.retentifi.ai/chatbot-embed.js"strategy="afterInteractive"onReady={() => window.initRetentifi('YOUR_AGENT_ID')}/>)}
With the Pages Router the split is the same, with getServerSideProps computing the hash. Not getStaticProps, a statically generated page would bake one user's signature into HTML served to everybody.
There is no server render, so the signature has to come from your API. Return it in the session payload you already fetch rather than adding a second request.
// no server render, so the hash rides along with the session// you already fetch — GET /api/me returns { id, email, retentifiHash }const metadata = useMemo(() => ({plan_tier: user?.plan,currency: user?.currency,current_arr: user?.arr}), [user?.plan, user?.currency, user?.arr])useEffect(() => {if (!user) returnwindow.Retentifi?.identify({id: user.id,email: user.email,name: user.name,hash: user.retentifiHash}, metadata)}, [user, metadata])
Memoise the metadata object. An inline literal is a new reference on every render, so the effect never settles, and you post a message per render for nothing.
For inline on any of these, add an embed block containing the container div where the agent should sit.
The user block is missing, or user.id is empty. Without an id nothing is recorded at all — it is a no-op by design, not a partial failure. In a single-page app the id usually is not known yet when initRetentifi runs, which is what Retentifi.identify() is for.
One or more pages render the agent without billing metadata. Those accounts are left out of the totals rather than counted as zero, and the dashboard says how many — a total that quietly omits an account is worse than one that admits the gap.
In order of likelihood: signing the email instead of the id, signing a number where the id is a string elsewhere, whitespace around the key read from the environment, or a rotation more than 24 hours old with a server still on the previous key. The digest must be lowercase hex of HMAC-SHA256 over the user id alone.
Only strings, numbers and booleans survive. Nested objects and arrays are dropped, as are keys past the 40 unrecognised-key limit. Flatten it: billing_country, not billing: { country }.
The selector did not match. Check the browser console for “no element matching …”, and confirm the id in your markup matches the Target element setting exactly.
Check the placement is not inline (it is always open, so there is nothing to trigger), that the selector matches, and that Opened by is set to Your element.
Either the agent is switched off in its Layout settings, or the domain is not allowed for it. If the agent has a domain list, the site you are testing on has to be on it.
Snippet options beat the dashboard. If the snippet passes mode, that page keeps using it whatever you save. Remove the option to hand control back to the dashboard.