Tenant Integration Guide

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.


1. Embed the SDK

Recommended: stub + defer (2 tags, 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>

Minimal: single defer tag

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:

Drop the ?.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('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.


2. Track events

Declarative (recommended)

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.

Imperative

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.


3. Show popups from code

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:

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.

CTA handlers via q.on

Instead 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:

  1. Per-popup onCta callback (if you passed one) — wins if present.
  2. q.on() handler matching content.cta_action — wins if registered.
  3. 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.

Overrides (rare)

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

4. Server-side events (rewards)

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:

  1. Credits the userawait creditUser(userId, result.totalCredits).
  2. Pushes the popup to the client via whatever real-time channel you use (WebSocket, SSE, next API response, etc.).
  3. The client renders it with q.reward(popup):
socket.on('qamp_reward', (popup) => q.reward(popup));

Event types

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

Security notes


5. Optional: client-direct events via 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' });

Token TTL & refresh

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().


6. Identify authenticated users

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().

Switching users in the same tab

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.


7. Minimal end-to-end example

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.


8. Local development & testing

Staging vs production sites

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>

Disable the SDK in tests

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.

Developer dashboard mode

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.


9. Testing & verification checklist


10. Common pitfalls


Reference

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.