Add rewarded AI search in just a few minutes
Your users ask a question, compare two anonymous AI answers, pick the better one with a reason, and earn — credited by your server from our webhook, in your own currency. This page is the complete integration reference; an AI agent can implement from it end to end. Machine-readable summary: /llms.txt.
Get your keys Try the demo host
- Keys — copy
pk_…(public) andsk_…(server secret, shown once) from the dashboard. - Test in the browser now — no server code: open the demo host with your publishable key in test mode (Integration → step 4), ask, pick, choose a reason. A test
reward.earnedfires (never payable). - Server — mint a signed launch token per user (one small function, samples in §2).
- Page/app — load the loader, call
AIRewards.init(...)(§3). - Server — receive the
reward.earnedwebhook, verify the signature, credit your user, deduping onevent_id(§6). - Optional — check
GET /v1/availabilitybefore showing your “Earn with AI search” entry point (§7). - Go live — when test events land correctly, ask AIRewards to approve your account for live (payable) mode. Test mode keeps working throughout.
1 · Keys
| Key | Where it lives | What it does |
|---|---|---|
pk_… publishable | Client code, snippets, mobile apps | Identifies your publisher account. Safe to ship. |
sk_… secret | Your server only (env var) | Signs launch tokens; authenticates the Availability and Reporting APIs. Shown once; Owners can rotate it in Keys & API (the old key stops working immediately). |
whsec_… webhook secret | Your server only | Verifies webhook signatures. Shown masked after creation; Owners can rotate it on the Webhook page if it is lost. |
Never put sk_… or whsec_… in client code, repos, or logs.
2 · Launch token (your server)
One token per end-user session. Format (JWT-shaped, HS256):
base64url({"alg":"HS256","typ":"JWT"}) + "." + base64url(payload) + "." + base64url(HMAC_SHA256(signing_input, sk_…))
payload: { "pk": "pk_…", "user_id": "your-user-123", "metadata": {"placement": "earn_tab"}, "test_mode": false, "iat": 1760000000, "exp": 1760000600 }
Rules enforced by the server: exp − iat ≤ 900 seconds (15 minutes; 600 is a good default); user_id is required and should be an opaque, stable id per user (caps and reporting key on it) — never an email or name; metadata is an optional flat string map echoed verbatim in webhooks and reports (keep it small, for example placement or campaign); test_mode is optional.
Node.js (copy-paste)
const crypto = require('crypto');
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
function launchToken({ userId, metadata = {}, testMode = false }) {
const now = Math.floor(Date.now() / 1000);
const input = b64({ alg: 'HS256', typ: 'JWT' }) + '.' +
b64({ pk: process.env.AIREWARDS_PK, user_id: String(userId), metadata, test_mode: testMode, iat: now, exp: now + 600 });
return input + '.' + crypto.createHmac('sha256', process.env.AIREWARDS_SK).update(input).digest('base64url');
}
// In your page/app bootstrap handler:
// res.json({ airewardsToken: launchToken({ userId: req.user.id }) })
PHP
<?php
function airewards_token($userId, $metadata = [], $testMode = false) {
$b64 = fn($o) => rtrim(strtr(base64_encode(json_encode($o)), '+/', '-_'), '=');
$now = time();
$input = $b64(['alg' => 'HS256', 'typ' => 'JWT']) . '.' .
$b64(['pk' => getenv('AIREWARDS_PK'), 'user_id' => (string) $userId, 'metadata' => $metadata,
'test_mode' => $testMode, 'iat' => $now, 'exp' => $now + 600]);
return $input . '.' . rtrim(strtr(base64_encode(hash_hmac('sha256', $input, getenv('AIREWARDS_SK'), true)), '+/', '-_'), '=');
}
Python
import base64, hashlib, hmac, json, os, time
def _b64(o):
return base64.urlsafe_b64encode(json.dumps(o, separators=(',', ':')).encode()).rstrip(b'=').decode()
def launch_token(user_id, metadata=None, test_mode=False):
now = int(time.time())
signing_input = _b64({'alg': 'HS256', 'typ': 'JWT'}) + '.' + _b64({
'pk': os.environ['AIREWARDS_PK'], 'user_id': str(user_id),
'metadata': metadata or {}, 'test_mode': test_mode, 'iat': now, 'exp': now + 600})
sig = base64.urlsafe_b64encode(
hmac.new(os.environ['AIREWARDS_SK'].encode(), signing_input.encode(), hashlib.sha256).digest()
).rstrip(b'=').decode()
return signing_input + '.' + sig
When a token expires mid-visit, the widget tells your page (see §4): fetch a fresh token and call AIRewards.setLaunchToken(newToken) — no reload needed.
3 · Web embed
<script src="https://airewards.b-cdn.net/loader@1.js" async></script>
<div id="airewards" style="height:640px"></div>
<script>
window.addEventListener('airewards:ready', () => {
AIRewards.init({
publishableKey: 'pk_…',
launchToken: '<from your server>', // or omit + testMode: true for browser testing
container: '#airewards',
theme: { primaryColor: '#0f0f0f', accentColor: '#ffce33', fontFamily: "'Inter', sans-serif",
borderRadius: 14, elementRadius: 10, colorScheme: 'Light', showPoweredBy: true },
onEarn: (e) => { /* e.event_id — refresh UI; credit ONLY from the webhook */ },
onTokenExpired: () => { /* fetch a fresh token, then AIRewards.setLaunchToken(t) */ }
});
});
</script>
Theme tokens (the complete list the loader honours): primaryColor, accentColor, background, textColor, fontFamily, borderRadius (outer container), elementRadius (answer cards, buttons, inputs), colorScheme ('Light' or 'Dark'), showPoweredBy (the small footer line). Values saved in Dashboard → Appearance apply automatically when no theme is passed; an explicit theme wins per launch. Answer anonymity, the reason-chip step, and the ad slot cannot be themed away.
Test without server code: pass testMode: true with just your publishable key (or use the links in Integration → step 4). Unsigned launch is test-mode only; test events are flagged ("test_mode": true) and never payable. In live mode a signed token is always required.
The loader is served from the CDN at https://airewards.b-cdn.net/loader@1.js (versioned — pin that exact URL). Calling init twice is guarded; call the returned handle’s destroy() to tear down.
4 · Widget events (all stacks)
| Event | When | Payload |
|---|---|---|
airewards:ready (window) | Loader has loaded | — (then call AIRewards.init) |
onEarn callback / airewards:earned (window CustomEvent) | A pick + at least one reason was accepted and the event created | { event_id, test_mode } — same id as the webhook |
onTokenExpired / airewards:token-expired | The session or launch token expired | Fetch a fresh token and call AIRewards.setLaunchToken(t) |
Inside a native webview the same earn moment is also posted as { source: 'airewards', type: 'reward.earned', event_id, test_mode } via window.postMessage and, where the container registers them, webkit.messageHandlers.airewards (iOS), AIRewardsBridge (Android), and ReactNativeWebView (React Native). The bridge is a UI hint; balances are credited only from the server webhook.
5 · Native webviews
General rules for every stack: (1) your app fetches a launch token from your server first — never mint tokens in the app with sk_…; (2) enable JavaScript and DOM storage; (3) listen for the earn bridge to refresh native UI; (4) handle token expiry by fetching a fresh token and handing it to the widget; (5) credit only from the webhook.
iOS (WKWebView, Swift)
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "airewards") // WKScriptMessageHandler
let webView = WKWebView(frame: .zero, configuration: config)
// Load your hosted bootstrap page that calls AIRewards.init with a server-fetched token.
func userContentController(_ c: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any], body["source"] as? String == "airewards",
body["type"] as? String == "reward.earned" else { return }
refreshBalanceFromYourBackend() // your backend credits on the webhook; this is only the UI hint
}
Android (WebView, Kotlin)
class Bridge { @JavascriptInterface fun postMessage(json: String) { /* parse: source/type/event_id */ } }
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.addJavascriptInterface(Bridge(), "AIRewardsBridge")
// Only ever load your own bootstrap page (with a server-fetched token) in this webview —
// addJavascriptInterface is unsafe with untrusted URLs.
React Native (react-native-webview)
<WebView
source={{ uri: yourBootstrapUrl }} // page that calls AIRewards.init with your token
onMessage={(e) => {
const m = JSON.parse(e.nativeEvent.data);
if (m.source === 'airewards' && m.type === 'reward.earned') refreshBalance();
}}
/>
Flutter (webview_flutter)
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('AIRewardsBridge', onMessageReceived: (msg) {
// parse msg.message: {source: 'airewards', type: 'reward.earned', event_id}
})
..loadRequest(Uri.parse(yourBootstrapUrl));
6 · Webhooks
Endpoint: the HTTPS URL saved in Dashboard → Webhook. POST, JSON. Headers:
AIRewards-Signature: t=1760000123,v1=5257a869…
AIRewards-Event-Id: evt_… (same id as the body's "id")
{
"id": "evt_…", "type": "reward.earned",
"created_at": "2026-10-17T12:00:05Z", "test_mode": false,
"publisher_id": "pub_…", "user_id": "your-user-123",
"metadata": { "placement": "earn_tab" },
"duel_id": 12345, "conversation_id": "c_…",
"winner": "A", "attributes": ["More accurate", "Clearer"]
}
Rules: respond with any 2xx within 10 seconds (do slow work async); verify the signature over the raw body bytes (tolerance 5 minutes); dedupe on event_id — retries and dashboard resends reuse it; delivery is at-least-once with ~24h exponential backoff, then dead-letter (visible in your Webhook page). The payload carries no money fields: what you credit your user is your own points logic, and revenue reconciles through the Reporting API.
Verify the signature (Node, copy-paste)
const crypto = require('crypto');
function verifyWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(String(header || '').split(',').map((s) => s.split('=')));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// Express: capture the raw body BEFORE any JSON parser touches it:
app.post('/airewards/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body.toString('utf8'), req.headers['airewards-signature'], process.env.AIREWARDS_WHSEC)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// TODO(you): if event.id already credited, return 200 immediately (idempotency).
// TODO(you): credit event.user_id in your currency; never trust a browser event for this.
res.status(200).end();
});
Fastest path to a known-good handler: save your URL, click Send test event on the Webhook page, and watch the delivery row go green with your real signed test payload ("test_mode": true — do not credit for it).
7 · Availability
GET https://jutera.com/v1/availability?user_id=your-user-123
Authorization: Bearer [REDACTED]…
200 { "eligible": true, "reason": "available", "cache_ttl_seconds": 60 }
Call it server-side before rendering your “Earn with AI search” entry point; cache per user up to cache_ttl_seconds. Reasons are deliberately coarse: available, daily_cap_reached, publisher_paused (also used for global maintenance pauses), no_fill_expected, user_ineligible. Never expose sk_… to the client to make this call — the widget itself degrades gracefully if you skip it.
8 · Reporting API
All endpoints use Authorization: Bearer [REDACTED]… and return as_of with every response. Revenue is estimated/provisional until the settlement job moves it to payable; events are near-real-time. Test-mode events are excluded from revenue and returned only when you ask for them.
Daily funnel + revenue
GET https://jutera.com/v1/reports/daily?from=2026-10-11&to=2026-10-17
→ { "publisher_id": "pub_…", "from": "…", "to": "…", "as_of": "…", "days": [
{ "day": "2026-10-17", "chats": 12, "duels": 9, "picks": 7, "users": 5,
"earned": 4, "earned_test": 1,
"provisional_cents": 0, "payable_cents": 0, "gross_cents": 0 } ] }
One zero-filled row per day in the range (default: last 7 days): chats (conversations started), duels, picks (duels with a chosen winner), users (distinct chat users), earned / earned_test, and revenue cents split provisional_cents vs payable_cents. Add &group_by=metadata.placement (any metadata key) for a per-value earned/revenue breakdown in groups.
Event list (cursor paginated)
GET https://jutera.com/v1/reports/events?from=2026-10-11&to=2026-10-17&limit=25
[&user_id=…] [&test_mode=true] [&cursor=]
→ { "events": [ { "event_id": "evt_…", "type": "reward.earned", "test_mode": false,
"user_id": "…", "duel_id": 42, "winner": "A", "attributes": ["Clearer"],
"state": "provisional", "publisher_cents": 0, "floor_applied": false,
"created_at": "…", "payable_at": null, "webhook": "delivered" } ],
"next_cursor": "evt_… or null" }
Single event
GET https://jutera.com/v1/reports/events/evt_…
→ { "event": { …same shape as the list… }, "as_of": "…" } · 404 if it is not yours
The Dashboard → Reports CSV export mirrors the event list for the selected range.
9 · Errors, versioning
Errors use the envelope { "error": { "code": "…" } }. Codes you may see: unauthorized (bad/missing sk_…), invalid_token and token_expired (launch token), not_found, and — from the widget session flow — the coarse availability reasons above. APIs are versioned under /v1/; breaking changes ship as /v2/ with overlap. Pin the loader as loader@1.js.