JUTERAAIRewards SDKDocsSign in Get keys

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

  1. Keys — copy pk_… (public) and sk_… (server secret, shown once) from the dashboard.
  2. 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.earned fires (never payable).
  3. Server — mint a signed launch token per user (one small function, samples in §2).
  4. Page/app — load the loader, call AIRewards.init(...) (§3).
  5. Server — receive the reward.earned webhook, verify the signature, credit your user, deduping on event_id (§6).
  6. Optional — check GET /v1/availability before showing your “Earn with AI search” entry point (§7).
  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

KeyWhere it livesWhat it does
pk_… publishableClient code, snippets, mobile appsIdentifies your publisher account. Safe to ship.
sk_… secretYour 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 secretYour server onlyVerifies 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)

EventWhenPayload
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-expiredThe session or launch token expiredFetch 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.