Integration guide

Embed the agent as a side panel or inline block

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.

Choosing a placement

Floating bubble

Launcher in a corner that opens a small chat window over the page. The default.

Side panel

Full-height drawer against one edge, with the page dimmed behind it.

Inline block

Sits inside your layout, always open, with no launcher.

Two ways to set it

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.

Side panel

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.

1. Set the placement

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 -->
Or set it entirely in code
window.initRetentifi('YOUR_AGENT_ID', {
mode: 'panel',
side: 'right', // or 'left'
width: 420 // pixels
});
How it behaves
  • Opens from the agent's launcher, or from your own element.
  • Closes on Escape, on a click outside it, or from its header control.
  • Below 992px it takes the full screen and drops the dimmed backdrop — a 420px drawer on a phone is the whole screen anyway.
  • It never affects your page's layout or scroll position.

Inline block

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.

1. Add a container

Put this wherever the agent should appear.

<div id="retentifi-chat"></div>
2. Add the script
<!-- 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 selector
height: 610 // pixels
});
Sizing

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.

  • Always open — there is no launcher, and toggle() does nothing.
  • It stays in the page flow at every screen size.
  • If the container is not in the DOM yet, the script waits up to 10 seconds, so a container your framework renders after load still works.

Opening from your own button

Applies to the bubble and side panel. Inline is always open, so triggers do not apply to it.

Any element, no configuration

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>
Replacing the launcher

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'
});

Options reference

Every option is optional. Anything you omit comes from the agent's saved configuration, and anything unset there falls back to the default.

OptionApplies toValuesDefault
modeAllbubble · panel · inlinebubble
positionBubblebottom-right · bottom-left · center-rightbottom-right
sideSide panelright · leftright
widthSide panelNumber, pixels420
targetInlineCSS selector#retentifi-chat
heightInlineNumber, pixels610
launcherBubble, side panelBooleantrue
triggerWhen launcher is falseCSS selector—

JavaScript API

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 it
agent.close(); // hide it
agent.toggle(); // flip, or toggle(true) / toggle(false)
agent.getSession(); // current session, or null
agent.clearSession(); // forget the conversation
agent.destroy(); // remove it from the page
 
window.Retentifi?.open?.(); // same controls, without the handle

Identifying signed-in users

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 person
email: 'alex@company.com',
name: 'Alex Rivera',
role: 'Admin',
companyId: 'org_acme_8819',
companyName: 'Acme Corporation'
}
});
FieldTypeWhat it does
idString, requiredYour own stable user id
emailStringStops the agent asking for contact details
nameStringShown throughout the dashboard
roleStringOwner, Admin, Member
companyIdStringGroups several users into one account
companyNameStringReadable company name
hashHex stringServer-computed signature — see Verifying identity
Use your own user id, not an email

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.

Recorded on load, not on chat

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.

When login happens after the page

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

Account metadata

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
}
});
KeyTypeWhat it does
plan_tierStringShown on the account, and usable in routing rules
currencyISO 4217Defaults to USD. Revenue is meaningless without it
billing_cycleStringMonthly, Annual, and so on
current_mrrNumberMonthly recurring revenue
current_arrNumberDrives every revenue total, and revenue at risk
expected_valueNumberContract value of a pilot, so it ranks with what it will become
renewal_dateISO 8601Renewal urgency, and the 90-day view
trial_ends_atISO 8601The same, for trials and pilots
has_billing_issueBooleanFlags a failing card before it lapses quietly
seats_activeNumberSeat contraction usually precedes cancellation by months
account_owner_emailStringWho on your side owns the relationship
Anything else is shown, but cannot drive anything

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.

Metadata merges, it does not replace

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.

Account type is worked out, not sent

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.

Verifying identity

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 browser
const hash = require('crypto')
.createHmac('sha256', process.env.RETENTIFI_SIGNING_KEY)
.update(user.id)
.digest('hex');
Never compute the signature in the browser

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.

1. Generate a signing key

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.

2. Ship the hash

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.

3. Turn on enforcement

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.

Rotation keeps the old key for 24 hours

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.

Frameworks and platforms

React / Next.js
// app/layout.tsx
import 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.
<Script
src="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.

Identifying users in a component tree

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 belongs
import { 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) return
window.Retentifi?.identify(identity, metadata)
}, [identity, metadata])
 
return (
<Script
src="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.

React without a server (Vite, Create React App)

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) return
 
window.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.

Website builders
  • WordPress: the theme footer, or a custom code block before </body>.
  • Webflow, Squarespace, Wix: site settings, custom code, footer.
  • Shopify: theme.liquid, before the closing </body> tag.

For inline on any of these, add an embed block containing the container div where the agent should sit.

Troubleshooting

Everyone shows as Guest User

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.

Revenue totals look too low

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.

The signature is always rejected

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.

Some metadata never appears

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

Inline: nothing appears where the container is

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.

My button does not open anything

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.

The agent does not load at all

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.

I changed the placement but the site shows the old one

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.