Skip to Content
AdsServer-Side Verification

Server-Side Verification

The client result.status === 'rewarded' is fine for low-stakes UX (revive animations, “+1 coin” toasts). For valuable rewards, your game server can confirm the ad was actually watched before crediting the player.

Coming soon: native Agent8 GameServer SDK integration.

Server-side ad verification will be exposed natively in a future release of the Agent8 GameServer SDK — you’ll get a single helper that wraps everything below. Until then (or if you run your own game server), use the manual flow on this page.

Endpoint

GET https://ads-verifier.verse8.io/ads/status?requestId=<requestId>

No auth required. Read-only — calling it twice returns the same record.

HTTPbody.statusAction
200verifiedGrant the reward
200dismissedDon’t grant — user closed the ad early
200failedDon’t grant — ad failure
202pendingRetry shortly with a bounded budget

The simplest rule: grant only when body.status === 'verified'.

Rules

  • Grant the reward amount from a server-side REWARD_TABLE keyed by placementId. Never use result.reward from the client as the source of truth.
  • The client supplies the placementId it requested — match it against your REWARD_TABLE and refuse unknown values.
  • /ads/status does not reserve or consume rewards. The same requestId keeps returning verified until the verifier’s record is gone, so track granted (userId, requestId) pairs in your own database and refuse duplicates with an atomic insert (unique constraint) inside the same transaction that credits the reward.

Server example

The client passes result.requestId from showRewarded to your server:

// Client const result = await Verse8Ads.showRewarded({ placementId: "revive-hero" }); if (result.status === "rewarded") { await fetch("/redeem-ad-reward", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId: result.requestId, placementId: "revive-hero" }), }); }

Any fetch-capable runtime works on the server (Node.js, Bun, Workers, etc.):

const REWARD_TABLE = { "revive-hero": { amount: 1, type: "revive" }, "double-stage-coins": { amount: 1, type: "coin-2x" }, }; async function checkVerified(requestId: string, attempts = 4): Promise<boolean> { for (let i = 0; i < attempts; i++) { const res = await fetch( `https://ads-verifier.verse8.io/ads/status?requestId=${encodeURIComponent(requestId)}`, ); if (!res.ok && res.status !== 202) return false; const body = await res.json(); if (body.status === "verified") return true; if (body.status === "pending") { await new Promise((r) => setTimeout(r, 1500)); continue; } return false; // dismissed | failed } return false; } app.post("/redeem-ad-reward", auth, async (req, res) => { const { requestId, placementId } = req.body; const userId = req.user.id; if (typeof requestId !== "string" || !requestId) { return res.status(400).json({ error: "missing_requestId" }); } const reward = REWARD_TABLE[placementId]; if (!reward) return res.status(400).json({ error: "unknown_placement" }); if (!(await checkVerified(requestId))) { return res.status(400).json({ error: "verification_failed" }); } // Atomic insert + credit. The unique constraint on (userId, requestId) // makes replays fail at the database layer; rollback prevents double-credit. try { await db.transaction(async (tx) => { await tx.adGrants.insert({ userId, requestId, placementId }); await tx.playerWallet.credit(userId, reward.type, reward.amount); }); } catch (err) { if (isUniqueConstraintError(err)) { return res.status(409).json({ error: "already_granted" }); } throw err; } res.json({ ok: true }); });

Daily caps and other game-economy limits live in your normal player-state code; they’re not part of /ads/status verification.

Last updated on