A practical walkthrough of what you (the tenant) write on your side to integrate Qamp. The platform does the heavy lifting — popup rendering, frequency/cooldown, impression tracking, audience targeting, reward evaluation — so your code stays minimal.
Everything below assumes you've been provisioned a SITE_ID (e.g. st_abc123) and a Qamp base URL (e.g. https://app.qamp.io). Admins manage campaigns and display rules in the Qamp portal; your job is wiring the SDK and your backend.
window.q works immediately)Paste the stub before the deferred SDK tag. window.q becomes usable from the very next byte of HTML — safe to call from inline scripts, bundle IIFEs, everything.
<!-- Tiny synchronous stub — installs window.q immediately -->
<script>!function(){var q=window.q=window.q||{};q.__q=q.__q||[];["track","identify","dismiss","reward","popup","on","off","resolve"].forEach(function(m){q[m]=q[m]||function(){q.__q.push([m,[].slice.call(arguments)])}})}();</script>
<!-- Full SDK loads deferred; drains the stub's queue on init -->
<script defer src="https://app.qamp.io/sdk/q.js" data-site="st_abc123"></script>
If you don't have inline scripts that run during HTML parsing, a single tag is enough:
<script defer src="https://app.qamp.io/sdk/q.js" data-site="st_abc123"></script>
Tradeoff: any code that executes during HTML parsing, before the browser reaches the deferred tag, will see window.q as undefined. This includes inline <script> bundles placed after the SDK tag, inline IIFEs, and any script without defer/async. In that case either install the stub above or use window.q?. on those specific call sites. Code that runs after parse completion — DOM event handlers, DOMContentLoaded, framework lifecycles, timers — is fine without the stub.
Once the SDK loads, it:
max_shows, cooldown_hours) via localStorage impression state.?. — q is always there (with the stub)With the recommended stub + defer install above, window.q is defined from the first byte after the stub tag. Every call before the full SDK initializes is buffered and drained after. That means:
q.track(...), q.popup(...), q.on(...) are safe to call from anywhere — no window.q?. guards, no if (window.q) checks, no race with DOMContentLoaded.q.track('signup_clicked'); // ✓ works even before SDK init
q.popup({ slug: 'welcome' }); // ✓ buffered, fires when ready
q.on('claim', () => goto('/store')); // ✓ registered, fires on click later
If you went with the single-tag minimal install, the buffering stub doesn't exist until q.js executes after HTML parsing — any code running during parsing will crash on q.track(...). Use window.q?.track(...) at those specific call sites, or switch to the recommended install. The rest of your codebase (event handlers, framework code, timers) is unaffected either way.
Annotate DOM elements. The SDK auto-binds a single delegated click listener — nested children (icons, spans) still match.
<button data-q-track="signup_clicked">Sign up</button>
<button data-q-track="level_started" data-q-props='{"level":3}'>Play</button>
<a href="/share" data-q-track="share_clicked">Share</a>
The listener is attached at the document level and walks up the DOM on each click, so dynamically-injected elements work automatically — there's nothing to re-bind after rendering new components, SPA route changes, or lazy-loaded UI.
For dynamic values that don't live in the DOM:
q.track('game_finished', { score: finalScore, duration_ms: elapsed });
Events also drive event-based campaign triggers — if an admin configures a campaign with trigger: event on signup_clicked, your data-q-track="signup_clicked" button will fire that campaign's popup.
Most popups should be triggered automatically by the rules configured in the Qamp portal (delay, scroll, URL match, etc.) — no code needed.
For on-demand popups your app fires (e.g. "show invite dialog when the user clicks X"), use q.popup({ slug }). The copy, image, theme, frequency, and cooldown all live on the Qamp portal — not in your code:
// Invite friends button
inviteBtn.addEventListener('click', () => {
q.popup({ slug: 'invite_friends' });
});
The admin-configured content.title, content.body, content.cta_label, content.theme, and content.frequency are fetched on first use and cached in memory for the page lifetime (not localStorage). Updating copy or theme in the portal takes effect:
Cmd+Shift+R) to bust the in-memory cache. The server also sets a short 30-second Cache-Control on /api/popups/:slug, so allow ~30s after an edit before testing via fresh sessions.Frequency state (max_shows, cooldown_hours) lives in localStorage under _q_d. Clear that key in DevTools to reset a popup that's been dismissed past its cap during QA.
q.onInstead of wiring an onCta callback per popup call, register named actions once. Admins set content.cta_action on the display rule (e.g. "invite_friend"); the SDK dispatches to your handler on CTA click:
q.on('invite_friend', () => openNativeShareSheet());
q.on('claim_preoffer', () => location.href = '/store?promo=1');
q.on('start_tutorial', () => startTutorialFlow());
CTA click resolution order:
onCta callback (if you passed one) — wins if present.q.on() handler matching content.cta_action — wins if registered.content.cta_url — browser navigates normally (fallback).Rules can safely define both cta_action and cta_url — the URL is the fallback if the handler isn't registered yet.
You can override the position or attach a one-off callback alongside the slug:
q.popup({
slug: 'invite_friends',
position: 'top', // override server default
onCta: customHandler, // overrides cta_action
onDismiss: () => console.log('user closed it'),
});
If you need a popup whose copy isn't managed in the portal (truly ad-hoc), pass content inline:
q.popup({
title: 'Saved!',
body: 'Your changes are live.',
ctaLabel: 'OK',
});
When a user hits a lifecycle milestone that should grant rewards (signup, game completion, purchase), your backend fires the event to Qamp. Qamp evaluates campaign rules and returns what to reward + what popup to show.
POST https://app.qamp.io/api/events
Authorization: Bearer <CAMPAIGN_API_KEY>
Content-Type: application/json
{
"type": "user_signup",
"userId": 12345,
"tenantId": 7,
"context": { "isNew": true, "email": "jane@example.com", "displayName": "Jane" }
}
Response:
{
"totalCredits": 250,
"popups": [{ "imageUrl": "...", "title": "Welcome!", "body": "+250 credits" }],
"rewards": [{ "slug": "welcome-bonus", "amount": 250, "type": "credits", "title": "Welcome" }],
"errors": [],
"warnings": []
}
Your backend then:
await creditUser(userId, result.totalCredits).q.reward(popup):socket.on('qamp_reward', (popup) => q.reward(popup));
| Event | Context fields you send | Fires |
|---|---|---|
user_signup |
isNew: true (required), referrerId, email, displayName, method |
welcome bonus + referral signup reward |
game_complete |
result: "win"|"loss", isFirstCompletedGame, gameResultId, referrerId |
per-game reward + first-game referral bonus |
share |
displayName |
share reward |
first_purchase |
amount, currency |
purchase-gated campaign |
CAMPAIGN_API_KEY is a server-to-server secret. Never ship it in client JS. Calls originate from your backend only.
Qamp does not deduplicate events. If you retry a request that succeeded, the user may get rewarded twice. Use your own idempotency — typically an events_fired table keyed by (userId, event_type) for once-per-user events, or (userId, eventId) for per-occurrence events like game_complete. The example below uses Postgres parameter syntax ($1, $2); adapt to your driver:
// Before firing: check you haven't already processed this event
const already = await db.query(
`SELECT 1 FROM events_fired WHERE user_id = $1 AND event_type = $2`,
[userId, 'user_signup']
);
if (already.rowCount) return;
const result = await postQampEvent({ type: 'user_signup', userId, ... });
// Only after success — record it so retries become no-ops.
await db.query(
`INSERT INTO events_fired (user_id, event_type) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[userId, 'user_signup']
);
q.resolve()If you don't want your backend in the loop for low-risk, non-reward events (default: share), the SDK can post directly:
// 5a. Your backend mints a short-lived JWT.
function mintQampToken(userId) {
return jwt.sign(
{ sub: userId, tenant_id: TENANT_ID, exp: Math.floor(Date.now()/1000) + 900 }, // 15 min
process.env.QAMP_SIGNING_SECRET, // rotate via Qamp admin portal
{ algorithm: 'HS256' }
);
}
// ship the token to the client on login / hydration
// 5b. Client stores it:
q.identify(userId, { name: 'Jane' }, { token });
// 5c. Fire events directly from the browser:
q.on('rewards_granted', ({ event, result }) => refreshCreditBalance(result.totalCredits));
q.resolve('share', { displayName: 'Jane' });
A 15-minute TTL is a good default, but tokens expire — SPAs with multi-hour sessions will see q.resolve() fail once the token lapses. Two workable patterns:
Option A — refresh on demand. Expose a small /api/qamp-token endpoint on your backend that returns a fresh token, and re-identify when needed:
async function refreshQampToken() {
const { token } = await fetch('/api/qamp-token').then(r => r.json());
q.identify(currentUserId, null, { token });
}
// Schedule ahead of expiry, e.g. every 10 min for a 15-min token.
// Keep a handle so you can stop it on logout / tab hide.
let refreshTimer = setInterval(refreshQampToken, 10 * 60 * 1000);
// Stop refreshing when the user is gone or the tab is backgrounded.
function stopQampRefresh() { clearInterval(refreshTimer); refreshTimer = null; }
onLogout(stopQampRefresh);
document.addEventListener('visibilitychange', () => {
if (document.hidden) stopQampRefresh();
else if (!refreshTimer) refreshTimer = setInterval(refreshQampToken, 10 * 60 * 1000);
});
Option B — longer TTL + rotation. Mint 24-hour tokens and rotate the signing secret (admin portal → rotate) if one is leaked. Simpler to run; larger blast radius if a token is stolen. Choose based on your session length vs. leak-risk tolerance.
Reward-granting events (user_signup, game_complete, etc.) stay on the /api/events server-to-server path — they require trusted context derivation and are rejected by /api/resolve unless explicitly opted-in by the Qamp operator. If you need to opt-in a type, coordinate with your Qamp contact.
Rate-limited to 30 req/min per user. resolve() auto-renders the returned popup and dispatches a rewards_granted action you can subscribe to via q.on().
Link anonymous session tracking to your user ID after login:
q.identify('user-12345', { name: 'Jane', plan: 'pro' });
Traits appear on the user's session in the Qamp dashboard. Call identify again on every page load — it's idempotent. Pass { token } as the third arg if you plan to use q.resolve().
If a second user signs in without a full page reload (e.g. account switcher, logout → login flow), call q.identify(newUserId, ...) — the SDK updates its stored userId and token for subsequent events. However, the browser's visitorId, session state, pageview history, and popup frequency state persist across the switch. If that cross-identity bleed matters for your use case (shared kiosks, admin impersonation), force a page reload on logout to reset everything cleanly. For typical single-user web/SPA sessions, calling identify again is enough.
What a typical integration looks like in total:
<!-- SDK -->
<script defer src="https://app.qamp.io/sdk/q.js" data-site="st_abc123"></script>
<!-- Declarative tracking -->
<button data-q-track="signup_clicked" onclick="startSignup()">Sign up</button>
<button data-q-track="invite_clicked">Invite friends</button>
<script>
// Register CTA action handlers once
q.on('invite_friend', () => openShareSheet());
q.on('claim_preoffer', () => location.href = '/store');
q.on('rewards_granted', ({ result }) => refreshBalance(result.totalCredits));
// Identify after login
async function afterLogin(user, token) {
q.identify(user.id, { plan: user.plan }, { token });
}
// Fire on-demand popups (copy lives in Qamp portal)
document.getElementById('invite-btn').onclick = () => q.popup({ slug: 'invite_friends' });
</script>
Your backend:
// After a signup completes — with idempotency:
async function fireSignupToQamp(user) {
// Skip if we've already processed this user's signup (retry-safe).
const existing = await db.query(
`SELECT 1 FROM events_fired WHERE user_id = $1 AND event_type = $2`,
[user.id, 'user_signup']
);
if (existing.rowCount) return;
const result = await fetch('https://app.qamp.io/api/events', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CAMPAIGN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'user_signup',
userId: user.id,
tenantId: TENANT_ID,
context: { isNew: true, email: user.email, displayName: user.name },
}),
}).then(r => r.json());
// Record AFTER success so a failed POST can be retried.
await db.query(
`INSERT INTO events_fired (user_id, event_type) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[user.id, 'user_signup']
);
if (result.totalCredits) await creditUser(user.id, result.totalCredits);
for (const popup of result.popups) await pushToClient(user.id, { type: 'qamp_reward', popup });
}
That's a complete integration. Everything else — campaign copy, trigger rules, audience targeting, reward amounts, popup theming, frequency caps — is managed by admins in the Qamp portal without redeploying your app.
Ask your Qamp contact for a separate SITE_ID per environment (e.g. st_abc123_dev, st_abc123_prod). Never point local dev at the production site — your pageviews and events pollute production analytics.
Wire the site id via env var:
<script defer src="https://app.qamp.io/sdk/q.js"
data-site="<%= process.env.QAMP_SITE_ID %>"></script>
For backend tests that mount your server code, stub the event-firing function:
// jest.setup.js
jest.mock('./lib/qamp', () => ({
fireQampEvent: jest.fn().mockResolvedValue({ totalCredits: 0, popups: [], rewards: [] }),
}));
For frontend unit/component tests, install a no-op window.q in your test setup so calls don't throw and don't make network requests:
// jest.setup.js or equivalent
global.window.q = new Proxy({}, { get: () => () => {} });
This is a silent no-op — every SDK call returns undefined and does nothing. It's enough to keep components from blowing up, but handlers you register via q.on() will be dropped, not stored. If a test needs to assert that the component called the SDK, or that a q.on() callback would fire, stub per-method with jest.fn() instead:
global.window.q = {
track: jest.fn(),
popup: jest.fn(),
on: jest.fn(),
identify: jest.fn(),
reward: jest.fn(),
resolve: jest.fn(),
};
// ... then in a test:
expect(window.q.track).toHaveBeenCalledWith('signup_clicked');
End-to-end tests that drive a real browser should point at the staging SITE_ID — popup rendering and frequency state are worth exercising against the real SDK.
While integrating, open your browser devtools → Application → Local Storage and watch _q_ (session), _q_d (popup frequency state), and _q_ph (page history). Editing these by hand is the fastest way to simulate returning visitors, expired cooldowns, or specific audience states.
q.track('test_event') in the browser console → test_event appears in the events feed.test-popup in the Qamp portal → q.popup({ slug: 'test-popup' }) renders it.user_signup event from your backend with a new userId → response includes totalCredits > 0 if a welcome-bonus campaign is active.q.on('cta_action_name', ...) handler → clicking the CTA dispatches to your handler instead of navigating.data-site. The SDK warns in the console and goes inert — no requests fire. Check the dev console.CAMPAIGN_API_KEY to the browser. It's a server-only secret. Compromise = anyone can grant rewards./api/events on success. No dedup — user double-credits. Use the events_fired pattern in §4.SITE_ID. Your test events land in the real analytics pipeline. Always use a per-environment site id.q.identify() updates the user but session/visitor/frequency state persist. Force a reload on logout if that bleed is unacceptable for your product.data-q-props JSON in code. The delegated listener re-reads the attribute on every click, so mutating it dynamically (el.setAttribute('data-q-props', JSON.stringify(newProps))) works as expected. Don't parse it once at render time and keep a stale copy — just update the attribute when the data changes.q.track() from inline scripts that run during HTML parsing. A single <script defer src=".../q.js"> tag does NOT install the buffering stub until the SDK executes after parsing. Any inline bundle / IIFE / non-deferred script placed after the SDK tag will see window.q as undefined and crash with TypeError. Fix: use the stub-+-defer install pattern from §1 (recommended), or ?.-guard the specific parse-time call sites. Symptom: crashes on specific URLs that trigger parse-time code paths (e.g. ?ref=... referral landings) while other pages work fine.window.q.track as a function before first render. The stub is installed synchronously, so this works — but if you forgot the script tag entirely, window.q is undefined. Keep the script tag in the base layout.Full API reference, rate limits, error codes, and advanced topics (MCP, collection API, admin endpoints) are in integration-guide.md.
For questions or onboarding help, contact your Qamp point of contact.