Add Qamp to your app in a few steps. Each step has a Copy agent prompt button for AI-assisted integration. For deeper coverage (idempotency, token refresh, local testing, pitfalls) see the full tenant guide.
Add to your .env file. QAMP_API_KEY is a secret — never commit it.
QAMP_URL=https://campaign-manager.up.railway.app
QAMP_API_KEY=<your-api-key>
QAMP_TENANT_ID=<TENANT_ID>
QAMP_SITE_ID=<SITE_ID>
Paste both tags near the top of <head> (or just before </body>). The small inline stub installs window.q synchronously so any inline bundle / IIFE / non-deferred script can call q.track(...) without optional chaining. The full SDK loads deferred and drains the stub's buffered calls on init.
<!-- Synchronous stub — installs window.q immediately, safe for parse-time calls -->
<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, deferred -->
<script defer src="https://campaign-manager.up.railway.app/sdk/q.js" data-site="<SITE_ID>"></script>
<!-- For multi-environment setups, inject QAMP_URL/QAMP_SITE_ID at build time. -->
window.q?..Create a helper function to send lifecycle events to Qamp and get rewards back. Uses all four env vars from step 1.
const QAMP_TENANT_ID = parseInt(process.env.QAMP_TENANT_ID, 10);
const EMPTY = { rewards: [], totalCredits: 0, popups: [], sideEffects: [], errors: [], warnings: [] };
async function fireQampEvent(type, userId, context = {}) {
if (!process.env.QAMP_API_KEY || !process.env.QAMP_URL) return EMPTY;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${process.env.QAMP_URL}/api/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.QAMP_API_KEY}`
},
body: JSON.stringify({ type, userId, tenantId: QAMP_TENANT_ID, context }),
signal: controller.signal
});
if (!res.ok) { console.error(`Qamp: ${res.status}`); return { ...EMPTY, errors: [`HTTP ${res.status}`] }; }
return await res.json();
} catch (err) { console.error('Qamp:', err.message); return { ...EMPTY, errors: [err.message] }; }
finally { clearTimeout(timeout); }
}
Fire user_signup when a new user registers. Credit their account and show the reward popup.
const result = await fireQampEvent('user_signup', userId, {
isNew: true, // required — always pass literal true, not a variable
referrerId: referrerUserId // optional — triggers referral rewards for the referrer
});
// Credit the user
if (result.totalCredits > 0) await creditUser(userId, result.totalCredits);
// Persist any side effects (e.g. set_referred_by)
for (const fx of result.sideEffects) {
if (fx.action === 'set_referred_by') await setUserReferrer(fx.userId, fx.referrerId);
}
// Show reward popup — push to client via your websocket, SSE, or HTTP response
for (const popup of result.popups) {
sendToClient(userId, { type: 'qamp:reward', popup });
// sendToClient = your app's push mechanism, e.g.:
// ws.send(JSON.stringify(...)) or res.json({ reward: popup })
}
result.totalCredits will be > 0.Same pattern, same response handling. Handle totalCredits, popups, and sideEffects identically.
// Game complete — result is required ('win' or 'loss')
await fireQampEvent('game_complete', userId, {
result: 'win', // required — 'win' or 'loss'
referrerId: referrerUserId, // optional — triggers referral_game_complete
isFirstCompletedGame: true, // optional — required for referral_game_complete to fire
gameResultId: 'gr_abc123', // optional — passed through for logging
displayName: 'Chess Match' // optional — shown in admin dashboard
});
// Share — no required context
await fireQampEvent('share', userId, {});
With the stub + defer install from step 2, window.q is defined from the first byte after the stub tag and buffered calls drain on init — no optional chaining needed anywhere. (If you installed only the single defer tag, parse-time inline scripts still need q?. guards.) Call identify() on login and signup to link the anonymous session.
// Link session to logged-in user — call on login AND after signup
q.identify(userId, { name: 'Jane', plan: 'pro' });
// Show a reward popup returned from fireQampEvent
q.reward(popup); // popup object from result.popups
// Track a custom frontend event (or use [data-q-track] — see below)
q.track('button_click', { label: 'signup-cta' });
// Dismiss a specific campaign
q.dismiss('spring-sale');
Annotate DOM elements with data-q-track to fire events on click without imperative JS. The delegated listener covers dynamically-injected elements automatically.
<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>
data-q-props dynamically works as expected.Fire popups whose copy, theme, and frequency are managed in the Qamp portal — not hardcoded in your app. Use q.on() to wire named CTA handlers once, and admins reference them by name from the display rule.
// Register named CTA action handlers once (near app boot)
q.on('invite_friend', () => openNativeShareSheet());
q.on('claim_preoffer', () => location.href = '/store?promo=1');
// Fire a popup by its admin-defined slug — copy comes from the portal,
// not from your code. Edit title/body/theme in Qamp and it updates on next load.
inviteBtn.addEventListener('click', () => {
q.popup({ slug: 'invite_friends' });
});
// CTA click resolution order:
// 1. per-call onCta callback (if passed) — wins if present
// 2. q.on() handler for content.cta_action — wins if registered
// 3. content.cta_url navigation — fallback
// Rules can safely define both cta_action and cta_url.
Cache-Control so allow ~30s between an edit and a fresh-session test.Deeper reference: idempotency patterns, client-direct events via q.resolve(), JWT token refresh strategies, local testing / staging setup, frontend test stubs, and common pitfalls.
// Full guide with worked examples, rendered from the repo:
// ${QAMP_URL}/docs/tenant-guide
Two additional service-to-service endpoints for reading campaign and participation data. Same API key auth as fireQampEvent.
// Get all active promotions (for rendering in your UI)
const promos = await fetch(`${process.env.QAMP_URL}/api/promotions/active/full`, {
headers: { 'Authorization': `Bearer ${process.env.QAMP_API_KEY}`, 'X-Tenant-ID': String(QAMP_TENANT_ID) }
}).then(r => r.json());
// promos.promotions = [{ slug, title, trigger_type, reward_amount, ... }]
// Get a user's participation stats (progress, rewards granted)
const parts = await fetch(`${process.env.QAMP_URL}/api/participations/${userId}`, {
headers: { 'Authorization': `Bearer ${process.env.QAMP_API_KEY}`, 'X-Tenant-ID': String(QAMP_TENANT_ID) }
}).then(r => r.json());
// parts.participations = [{ slug, progress, reward_granted, max_reward_per_user, status }]
| Context | Header |
|---|---|
| Server-to-server | Authorization: Bearer <CAMPAIGN_API_KEY> |
| MCP / AI agent | Authorization: Bearer cm_live_<key> |
| Field | Description |
|---|---|
totalCredits | Sum of credit rewards. Credit the user’s account with this. |
popups | Pass each to window.q.reward() on the frontend. |
rewards | Per-campaign breakdown: slug, amount, type, title. |
errors | Fatal errors during evaluation. |
warnings | Non-fatal issues (e.g., isNew not true). |
| Status | Meaning |
|---|---|
| 400 | Invalid type, userId, or tenant mismatch |
| 401 | Missing or invalid Authorization header |
| 429 | Rate limited (30 req/min per IP) |
| Endpoint | Limit |
|---|---|
| POST /api/events | 30 req / 60s per IP |
| POST /api/collect | No limit (batch up to 50) |
| /mcp | 30 req / 60s per IP |