Integrate Pendo with Strapi to track user engagement and product adoption across your CMS-powered applications. Connect in-app analytics, user feedback, and guided onboarding to understand how users interact with your content and improve their experience

Pendo is a product experience platform that developers integrate into web and mobile applications to capture user behavior analytics and deliver in-app guidance. The platform operates through client-side SDKs—primarily a JavaScript agent for web applications—that automatically track page views, clicks, and form interactions while enabling you to inject dynamic UI elements like tooltips, modals, and multi-step walkthroughs.
From a technical perspective, Pendo functions as an analytics and engagement layer embedded in your application. The JavaScript SDK loads asynchronously, initializes with visitor and account metadata, and transmits event data to Pendo's analytics backend.
Developers use the SDK's JavaScript API to identify users via pendo.initialize() and pendo.identify(), send custom events with pendo.track(), and programmatically control guide behavior. The platform provides REST APIs (Engage API, Feedback API, Agent API) for managing guides, segments, and metadata programmatically.
Integrating Pendo with Strapi addresses content-driven application analytics through dual tracking capabilities, automated content workflow measurement, admin panel feature adoption visibility, and frontend content consumption analysis.
This integration implementation targets frontend applications consuming Strapi's API rather than embedding Pendo within Strapi's backend. The JavaScript SDK loads in your application's client layer, initializes with user data fetched from Strapi's authentication API, and tracks interactions with content delivered through Strapi's REST or GraphQL endpoints.
Place the Pendo installation snippet in your application's main HTML file or framework entry point. This creates a global pendo object and asynchronously loads the Pendo agent from the CDN at https://cdn.pendo.io/agent/static/.
For standard HTML applications, place the Pendo install script in your index.html before the closing </head> tag:
<script>
(function(apiKey){
(function(p,e,n,d,o){
var v,w,x,y,z;
o=p[d]=p[d]||{};
o._q=o._q||[];
v=['initialize','identify','updateOptions','pageLoad','track'];
for(w=0,x=v.length;w<x;++w)(function(m){
o[m]=o[m]||function(){o._q[m===v[0]?'unshift':'push']([m].concat([].slice.call(arguments,0)));};
})(v[w]);
y=e.createElement(n);y.async=!0;y.src='https://cdn.pendo.io/agent/static/'+apiKey+'/pendo.js';
z=e.getElementsByTagName(n)[0];z.parentNode.insertBefore(y,z);
})(window,document,'script','pendo');
})('YOUR_PENDO_API_KEY');
</script>Replace 'YOUR_PENDO_API_KEY' with your actual Pendo API key from your subscription settings. Use an environment variable (such as PENDO_API_KEY, NEXT_PUBLIC_PENDO_API_KEY for Next.js, or VITE_PENDO_API_KEY for Vue/Vite) rather than committing it directly to your codebase:
})('${process.env.NEXT_PUBLIC_PENDO_API_KEY}');For Next.js applications, load the script in _app.js:
// pages/_app.js
import { useEffect } from 'react';
import { useRouter } from 'next/router';
function MyApp({ Component, pageProps }) {
const router = useRouter();
useEffect(() => {
const script = document.createElement('script');
script.innerHTML = `
(function(apiKey){
(function(p,e,n,d,o){
var v,w,x,y,z;
o=p[d]=p[d]||{};
o._q=o._q||[];
v=['initialize','identify','updateOptions','pageLoad','track'];
for(w=0,x=v.length;w<x;++w)(function(m){
o[m]=o[m]||function(){o._q[m===v[0]?'unshift':'push']([m].concat([].slice.call(arguments,0)));};
})(v[w]);
y=e.createElement(n);y.async=!0;y.src='https://cdn.pendo.io/agent/static/'+apiKey+'/pendo.js';
z=e.getElementsByTagName(n)[0];z.parentNode.insertBefore(y,z);
})(window,document,'script','pendo');
})('${process.env.NEXT_PUBLIC_PENDO_API_KEY}');
`;
document.head.appendChild(script);
}, []);
useEffect(() => {
const handleRouteChange = () => {
if (window.pendo) {
window.pendo.pageLoad();
}
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events]);
return <Component {...pageProps} />;
}
export default MyApp;This is where most integrations get tricky—you need user context before Pendo can track anything meaningful. After user authentication completes, fetch the authenticated user's data from Strapi's /api/users/me endpoint and pass this metadata to pendo.initialize().
A utility function streamlines this initialization:
// utils/pendo.js
import axios from 'axios';
export async function initializePendoWithStrapiUser() {
try {
const token = localStorage.getItem('jwt');
if (!token) {
console.warn('No authentication token found');
return;
}
const response = await axios.get(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/users/me`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
const user = response.data;
if (window.pendo) {
window.pendo.initialize({
visitor: {
id: user.id.toString(),
email: user.email,
full_name: user.username,
role: user.role?.name || 'user',
creationDate: user.createdAt
},
account: {
id: user.organization?.id?.toString() || 'default',
name: user.organization?.name || 'Individual'
}
});
}
} catch (error) {
console.error('Failed to initialize Pendo:', error);
}
}For single-page applications, this initialization should occur after verifying the authenticated user's credentials and fetching their metadata from your authentication context. For Strapi implementations, retrieve the user's profile via the /api/users/me endpoint using the stored JWT token, then initialize Pendo with the returned user data mapped to Pendo's visitor and account object structure.
// components/AuthProvider.js
import { createContext, useContext, useEffect, useState } from 'react';
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('jwt');
if (token) {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/users/me`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
const userData = await response.json();
setUser(userData);
// Initialize Pendo after user data loads with Strapi user metadata
if (window.pendo) {
window.pendo.initialize({
visitor: {
id: userData.id.toString(),
email: userData.email,
full_name: userData.username,
role: userData.role?.name || 'user',
creationDate: userData.createdAt
},
account: {
id: userData.organization?.id.toString() || 'default',
name: userData.organization?.name || 'Individual'
}
});
}
} catch (error) {
console.error('Authentication failed:', error);
localStorage.removeItem('jwt');
}
}
setLoading(false);
};
loadUser();
}, []);
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
}Raw page views don't tell you much. What matters is understanding which content actually drives user behavior. When users view articles, watch videos, or interact with content delivered from Strapi, capture these interactions using Pendo's Track Events functionality with the pendo.track() method.
Custom Track Events capture specific user actions beyond automatic interaction tracking. The basic syntax for tracking events is:
pendo.track('eventName', {
property1: 'value1',
property2: 'value2'
});For Strapi content interactions, implement a reusable tracking utility:
// utils/contentTracking.js
export const trackContentEvent = (eventName, contentMetadata) => {
if (!window.pendo) {
console.warn('Pendo not initialized');
return;
}
pendo.track(eventName, {
contentId: contentMetadata.id?.toString(),
contentType: contentMetadata.__component || contentMetadata.type,
title: contentMetadata.title,
category: contentMetadata.category?.name,
author: contentMetadata.author?.username,
publishDate: contentMetadata.publishedAt,
timestamp: Date.now()
});
};
// Specific tracking functions for content lifecycle events
export const trackContentViewed = (article) => {
trackContentEvent('ContentViewed', article);
};
export const trackContentEngagement = (contentId, engagementData) => {
pendo.track('ContentEngagement', {
contentId: contentId.toString(),
durationSeconds: engagementData.duration,
scrollPercentage: engagementData.scrollDepth,
interactionType: 'read'
});
};
export const trackContentCreated = (newArticle) => {
trackContentEvent('ContentCreated', {
...newArticle,
status: newArticle.publishedAt ? 'published' : 'draft'
});
};Important constraints: According to Pendo's Track Events documentation, event properties are limited to 512 bytes total JSON size, property names must be 32 characters or fewer, and property values should be strings or booleans for optimal reporting functionality.
These tracking functions integrate into your components:
// pages/articles/[slug].js
import { useState, useEffect } from 'react';
import { trackContentViewed, trackContentEngagement } from '../utils/contentTracking';
export default function Article({ article }) {
const [startTime] = useState(Date.now());
const [scrollDepth, setScrollDepth] = useState(0);
useEffect(() => {
// Track initial page view
trackContentViewed(article);
// Track scroll depth
const handleScroll = () => {
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY;
const depth = Math.round((scrollTop / (documentHeight - windowHeight)) * 100);
if (depth > scrollDepth) {
setScrollDepth(depth);
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
// Track engagement on component unmount
const duration = Math.round((Date.now() - startTime) / 1000);
trackContentEngagement(article.id, {
duration: duration,
scrollDepth: scrollDepth
});
};
}, [article.id, startTime, scrollDepth]);
return (
<div>
<h1>{article.attributes.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.attributes.body }} />
</div>
);
}Client-side tracking misses a critical piece—content operations that happen server-side need tracking too, or you'll have blind spots in your analytics. Backend operations like content creation, publication, and user actions happen outside the browser and need server-side tracking.
The approach involves creating a function that sends Track Event payloads to Pendo's tracking API (https://track.pendo.io/track/api/v1/track) with the visitor ID, account ID, event name, and relevant properties.
Register this function in Strapi's lifecycle hooks using strapi.db.lifecycles.subscribe() in your application's bootstrap function, passing the visitor and account IDs from the authenticated context along with metadata about the content action.
A Pendo service in your Strapi application handles this:
// src/api/article/services/pendo.js
const axios = require('axios');
module.exports = {
async trackEvent(visitorId, accountId, eventName, properties) {
try {
const trackEventPayload = {
type: "track",
event: eventName,
visitorId: visitorId.toString(),
accountId: accountId.toString(),
timestamp: Date.now(),
properties: properties,
context: {
ip: this.ctx?.request?.ip,
userAgent: this.ctx?.request?.header['user-agent'],
url: this.ctx?.request?.url
}
};
await axios.post(
'https://track.pendo.io/track/api/v1/track',
trackEventPayload,
{
headers: {
'Content-Type': 'application/json',
'x-pendo-integration-key': process.env.PENDO_SHARED_SECRET
}
}
);
} catch (error) {
strapi.log.error('Pendo tracking failed:', error);
}
}
};Register lifecycle hooks to trigger tracking:
// src/api/article/content-types/article/lifecycles.js
const axios = require('axios');
module.exports = {
async afterCreate(event) {
const { result, params } = event;
// Track content creation server-side using Pendo Track API
if (params.data.createdBy) {
const trackEventPayload = {
type: "track",
event: "ArticleCreated",
visitorId: params.data.createdBy.id.toString(),
accountId: params.data.organization?.id.toString() || 'default',
timestamp: Date.now(),
properties: {
articleId: result.id.toString(),
title: result.title,
category: result.category?.name,
status: result.publishedAt ? 'published' : 'draft'
}
};
try {
await axios.post('https://track.pendo.io/track/api/v1/track', trackEventPayload, {
headers: {
'Content-Type': 'application/json',
'x-pendo-integration-key': process.env.PENDO_SHARED_SECRET
}
});
} catch (error) {
console.error('Pendo tracking error:', error);
}
}
},
async afterUpdate(event) {
const { result, params } = event;
const pendoService = strapi.service('api::article.pendo');
// Track publication events
if (result.publishedAt && !params.data.previousPublishedAt) {
await pendoService.trackEvent(
params.data.updatedBy.id,
params.data.organization?.id || 'default',
'ArticlePublished',
{
articleId: result.id.toString(),
title: result.title,
timeToPublish: Date.now() - new Date(result.createdAt).getTime()
}
);
}
}
};Applications enforcing Content Security Policy headers need Pendo's domains whitelisted. The Pendo CDN domain https://cdn.pendo.io must be whitelisted in your CSP directives. In Next.js, update your next.config.js to configure security headers:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.pendo.io https://pendo-io-static.storage.googleapis.com",
"connect-src 'self' https://track.pendo.io https://app.pendo.io",
"img-src 'self' data: https://cdn.pendo.io https://pendo-io-static.storage.googleapis.com",
"style-src 'self' 'unsafe-inline' https://cdn.pendo.io https://pendo-io-static.storage.googleapis.com"
].join('; ')
}
]
}
];
}
};For applications deployed behind Nginx or other reverse proxies, configure CSP headers at the proxy level:
# nginx.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.pendo.io; connect-src 'self' https://track.pendo.io; img-src 'self' data: https://cdn.pendo.io; font-src 'self';";The browser console command pendo.validateEnvironment() verifies the installation:
pendo.validateEnvironment()This outputs installation status, current visitor and account metadata, and configuration warnings. Check that visitor and account IDs match your Strapi user data, the API key corresponds to your subscription, and no initialization errors appear in the console.
A technical documentation portal demonstrates the practical value of combining Strapi's content management with Pendo's analytics capabilities. This example implements a knowledge base where technical writers manage documentation through Strapi's admin panel while Pendo tracks reader behavior to identify content gaps and optimize documentation structure.
The portal consists of three layers:
Content Layer (Strapi): Stores documentation articles as collection types with attributes for title, content body, category, related articles, and code examples. Strapi's REST API exposes documentation content with filtering, search, and population capabilities.
Presentation Layer (Next.js): Server-side renders documentation pages for SEO while maintaining interactivity for search, navigation, and feedback collection. The application fetches content from Strapi's API and implements client-side routing.
Analytics Layer (Pendo): Implements custom event tracking through Pendo's JavaScript SDK to monitor documentation interactions including page views, scroll depth, time on page, and navigation paths. Developers configure Track Events to capture specific user interactions such as code snippet copying, external link clicks, and feedback submissions.
Custom Track Events allow measurement of business-critical documentation actions with customizable metadata properties, enabling analytics for content-specific user behavior.
Documentation content type in Strapi with these fields:
// src/api/documentation/content-types/documentation/schema.json
{
"kind": "collectionType",
"collectionName": "documentations",
"info": {
"singularName": "documentation",
"pluralName": "documentations",
"displayName": "Documentation"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title"
},
"content": {
"type": "richtext",
"required": true
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category"
},
"relatedArticles": {
"type": "relation",
"relation": "manyToMany",
"target": "api::documentation.documentation"
},
"codeExamples": {
"type": "component",
"repeatable": true,
"component": "content.code-block"
},
"viewCount": {
"type": "integer",
"default": 0
},
"searchKeywords": {
"type": "json"
}
}
}Custom controller to increment view counts and track searches using Pendo's Track Events API:
// src/api/documentation/controllers/documentation.js
const { createCoreController } = require('@strapi/strapi').factories;
const axios = require('axios');
async function sendPendoTrackEvent(visitorId, accountId, eventName, properties) {
const trackEventPayload = {
type: "track",
event: eventName,
visitorId: visitorId.toString(),
accountId: accountId.toString(),
timestamp: Date.now(),
properties: properties
};
try {
await axios.post('https://track.pendo.io/track/api/v1/track', trackEventPayload, {
headers: {
'Content-Type': 'application/json',
'x-pendo-integration-key': process.env.PENDO_SHARED_SECRET
}
});
} catch (error) {
console.error('Pendo tracking error:', error);
}
}
module.exports = createCoreController('api::documentation.documentation', ({ strapi }) => ({
async findOne(ctx) {
const { slug } = ctx.params;
const entity = await strapi.db.query('api::documentation.documentation').findOne({
where: { slug },
populate: ['category', 'relatedArticles', 'codeExamples']
});
if (!entity) {
return ctx.notFound();
}
// Increment view count
await strapi.db.query('api::documentation.documentation').update({
where: { id: entity.id },
data: { viewCount: (entity.viewCount || 0) + 1 }
});
// Track content view in Pendo
const userId = ctx.state.user?.id;
if (userId) {
await sendPendoTrackEvent(userId, ctx.state.user.organization?.id || 'default', 'ContentViewed', {
contentId: entity.id.toString(),
contentType: 'documentation',
title: entity.title,
category: entity.category?.name,
viewCount: (entity.viewCount || 0) + 1
});
}
return this.transformResponse(entity);
},
async search(ctx) {
const { query } = ctx.request.query;
const results = await strapi.db.query('api::documentation.documentation').findMany({
where: {
$or: [
{ title: { $containsi: query } },
{ content: { $containsi: query } },
{ searchKeywords: { $contains: query } }
]
},
populate: ['category']
});
// Track search event in Pendo
const userId = ctx.state.user?.id;
if (userId) {
await sendPendoTrackEvent(userId, ctx.state.user.organization?.id || 'default', 'SearchPerformed', {
searchQuery: query,
resultCount: results.length,
timestamp: Date.now()
});
}
return results;
}
}));Documentation viewer with integrated tracking capabilities:
// pages/docs/[slug].js
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import axios from 'axios';
export default function DocumentationPage({ article }) {
const router = useRouter();
const [scrollDepth, setScrollDepth] = useState(0);
const [timeOnPage, setTimeOnPage] = useState(0);
const startTimeRef = useRef(Date.now());
useEffect(() => {
if (window.pendo && article) {
window.pendo.track('content_viewed', {
contentId: article.id.toString(),
title: article.attributes.title,
category: article.attributes.category?.name,
slug: article.attributes.slug
});
}
const handleScroll = () => {
const scrollPercentage = (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100;
setScrollDepth(Math.round(scrollPercentage));
};
const timeInterval = setInterval(() => {
const elapsed = Math.round((Date.now() - startTimeRef.current) / 1000);
setTimeOnPage(elapsed);
if (window.pendo && elapsed > 0 && elapsed % 30 === 0) {
pendo.track('page_time_milestone', {
durationSeconds: elapsed.toString(),
pageUrl: window.location.pathname,
engagementType: 'time_based'
});
}
}, 5000);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
clearInterval(timeInterval);
if (window.pendo) {
window.pendo.track('documentation_engagement', {
article_id: article.id.toString(),
duration_seconds: Math.round((Date.now() - startTimeRef.current) / 1000),
scroll_depth: scrollDepth,
completion_status: scrollDepth > 80 ? 'complete' : 'partial'
});
}
};
}, [article, scrollDepth]);
const handleCodeCopy = (codeSnippet) => {
navigator.clipboard.writeText(codeSnippet);
if (window.pendo) {
window.pendo.track('CodeSnippetCopied', {
articleId: article.id.toString(),
language: codeSnippet.language,
snippetLength: codeSnippet.code.length
});
}
};
const handleRelatedArticleClick = (relatedArticle) => {
if (window.pendo) {
window.pendo.track('RelatedArticleClicked', {
sourceArticleId: article.id.toString(),
targetArticleId: relatedArticle.id.toString(),
targetTitle: relatedArticle.attributes.title
});
}
router.push(`/docs/${relatedArticle.attributes.slug}`);
};
return (
<div className="documentation-container">
<article>
<header>
<h1>{article.attributes.title}</h1>
{article.attributes.category && (
<span className="category">
{article.attributes.category.data.attributes.name}
</span>
)}
</header>
<div dangerouslySetInnerHTML={{ __html: article.attributes.content }} />
{article.attributes.codeExamples?.length > 0 && (
<section className="code-examples">
<h2>Code Examples</h2>
{article.attributes.codeExamples.map((example, index) => (
<div key={index} className="code-block">
<div className="code-header">
<span>{example.language}</span>
<button onClick={() => handleCodeCopy(example)}>
Copy
</button>
</div>
<pre>
<code>{example.code}</code>
</pre>
</div>
))}
</section>
)}
{article.attributes.relatedArticles?.data?.length > 0 && (
<aside className="related-articles">
<h3>Related Documentation</h3>
<ul>
{article.attributes.relatedArticles.data.map((related) => (
<li key={related.id}>
<a onClick={() => handleRelatedArticleClick(related)}>
{related.attributes.title}
</a>
</li>
))}
</ul>
</aside>
)}
</article>
</div>
);
}
export async function getServerSideProps({ params }) {
const { slug } = params;
const response = await axios.get(
`${process.env.STRAPI_URL}/api/documentations/${slug}`,
{
params: {
populate: ['category', 'relatedArticles', 'codeExamples']
}
}
);
return {
props: {
article: response.data.data
}
};
}Track custom user interactions and content engagement using Pendo's event tracking capabilities:
// components/DocumentationSearch.js
import { useState } from 'react';
import axios from 'axios';
export default function DocumentationSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
try {
const response = await axios.get(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/documentations/search`,
{
params: { query }
}
);
setResults(response.data);
if (window.pendo) {
window.pendo.track('DocumentationSearched', {
searchTerm: query,
resultCount: response.data.length,
hasResults: response.data.length > 0
});
}
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
};
const handleResultClick = (result, index) => {
if (window.pendo) {
window.pendo.track('SearchResultClicked', {
searchTerm: query,
resultPosition: index + 1,
articleTitle: result.title,
articleId: result.id.toString()
});
}
};
return (
<div className="search-container">
<form onSubmit={handleSearch}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documentation..."
/>
<button type="submit" disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</button>
</form>
{results.length > 0 && (
<div className="search-results">
{results.map((result, index) => (
<a
key={result.id}
href={`/docs/${result.slug}`}
onClick={() => handleResultClick(result, index)}
>
<h4>{result.title}</h4>
<p>{result.category?.name}</p>
</a>
))}
</div>
)}
</div>
);
}This implementation provides several actionable analytics insights:
Content performance metrics: Track which documentation articles receive the most views, identify articles with high bounce rates (low scroll depth), and measure average time spent on different documentation categories.
Track custom events: Implement custom event tracking using Pendo's Track Events API to capture specific user actions such as content views, engagement metrics, and feature interactions. Developers can track events with custom properties using pendo.track('eventName', {property: 'value'}) to measure user behavior including article consumption, guide completions, and content lifecycle actions within Strapi-based applications.
User journey mapping: Understand typical navigation paths through documentation, identify articles that effectively guide users to related content, and measure documentation engagement based on scroll depth and time-on-page metrics.
Content engagement patterns: Track code snippet copy rates and code block interaction metrics to identify valuable examples, monitor related article click-through rates, and identify articles that consistently lead to successful problem resolution (measured by bounce rates, return visit frequency, and task completion indicators).
The combination of Strapi's content flexibility with Pendo's behavioral analytics creates a feedback loop where usage data directly informs content strategy and documentation improvements.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Pendo documentation.
Yes, through custom plugin development. Build a Strapi v5 plugin using the Plugin SDK to inject the Pendo snippet into the admin panel's initialization sequence—implement it within the admin component layer and initialize Pendo with administrator metadata from /api/users/me. Use separate Pendo API keys or account IDs to isolate admin panel tracking from frontend analytics.
Add conditional checks in lifecycle hook handlers to prevent double-tracking. Verify operations originate from user actions (check for params.data.createdBy) before sending track events. For operations happening both client and server-side, differentiate events with distinct names like ContentCreatedClient versus ContentCreatedBackend, keeping in mind Track Event property JSON payloads are limited to 512 bytes with string or boolean property types.
Register lifecycle hooks in your content type using afterCreate, afterUpdate, or afterPublish events. Within the hook handler, send a POST request to https://track.pendo.io/track/api/v1/track with visitor ID, account ID, event name, and content metadata. Include the x-pendo-integration-key header with your Pendo shared secret for server-side tracking without browser-based SDK initialization.
Use Strapi's user ID as Pendo's visitor ID for authenticated users, converted to a string: visitor: { id: strapiUser.id.toString() }. For anonymous users, initialize with an anonymous ID and transition to authenticated state using pendo.identify() after login—never re-initialize with a new ID. Avoid email addresses as visitor IDs if your application allows email changes, as changing IDs fragments analytics history.
Set up environment-specific API keys in .env.local (development) and deployment variables (production) to isolate development data. Configure visitor ID prefixes based on environment: dev-${userId} for development versus ${userId} for production. Implement environment-based conditionals that skip server-side tracking in non-production environments, and verify CSP configuration in staging to ensure script-src allows https://cdn.pendo.io/agent/static/ before production deployment.