Every AdGrove unlock produces a signed receipt your server can verify. Gate downloads, credits or premium content on a completion AdGrove has confirmed, never on a browser event.
AdGrove serves rewarded video: the viewer watches a short ad in full, then your content unlocks. Skipping is disabled platform-wide, so a completion always means the full creative was watched.
The integration has three moving parts. The player runs in the browser and reports the impression. AdGrove issues a short-lived, HMAC-signed unlock receipt for that impression. Your server exchanges the receipt for a verified completion before it releases anything of value.
Base URL
https://gxztgogbzrpnzfceujno.supabase.co/functions/v1/publisher-api
Authenticate every call with an API key issued for your website: Authorization: Bearer ag_api_live_...
Keys are created per website, scoped, and rate limited. Ask us for a key from your publisher account; the plaintext key is shown once and cannot be retrieved again.
Advanced integration guides
For receiver implementations (signed completion postbacks) and full API key scope strategy (verify-only keys, widening keys in place, legacy fallback), see the advanced integration guides. These are available on request and are not part of the standard installation.
Load the script once, then wrap each gated action in an <adgrove-gate> element. Set data-content-ref to your own stable identifier for the item being unlocked: it flows through the receipt, the postback and the reporting endpoints, so per-item revenue attribution comes for free.
<!-- 1. Load the AdGrove player once per page -->
<script
src="https://gxztgogbzrpnzfceujno.supabase.co/functions/v1/player-js"
data-api-key="YOUR_WEBSITE_API_KEY"
data-placement-key="YOUR_PLACEMENT_KEY"
defer
></script>
<!-- 2. Gate the content the viewer wants to reach -->
<adgrove-gate data-content-ref="photo_10422">
<button type="button">Watch a short ad to download</button>
</adgrove-gate>The player self-hosts nothing else: fonts, geolocation and creative are all served from the AdGrove CDN, and updates reach your site automatically with no redeploy.
When the ad completes, the player dispatches a composed adgrove-unlock CustomEvent that crosses shadow DOM boundaries. Its detail.receipt is the signed proof of completion.
document.addEventListener('adgrove-unlock', async (event) => {
const { receipt, contentRef, impressionId } = event.detail;
// Never trust the browser. Send the receipt to your own server and let
// your server ask AdGrove whether this completion is genuine.
const res = await fetch('/api/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receipt, contentRef, impressionId }),
});
if (res.ok) {
const { downloadUrl } = await res.json();
window.location.href = downloadUrl;
}
});The event alone is not proof
Anything in the page can dispatch a DOM event. Treat the event as a prompt to call your own server, and treat the receipt as the only evidence. Receipts are valid for five minutes and are single-use once redeemed.
The event also fires when inventory is momentarily unavailable, so the viewer is never blocked. Those unlocks carry adWatched: false and no receipt: decide for yourself whether to grant the reward, and expect no earnings for them. Each event carries detail.contentRef so a single handler can serve every gate on the page.
Post the receipt to the verification endpoint from your backend. AdGrove checks the signature, the expiry, that the impression exists and was watched to completion, and that the receipt has not already been spent. Pass redeem: true to consume it atomically in the same call.
POST https://gxztgogbzrpnzfceujno.supabase.co/functions/v1/publisher-api/v1/unlocks/verify
Authorization: Bearer ag_api_live_xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"receipt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"content_ref": "photo_10422",
"nonce": "dl_7f3c2a91"
}// 200: verified and redeemed, exactly once
{
"verified": true,
"redeemed": true,
"already_redeemed": false,
"redeem_count": 1,
"data": {
"impression_id": "0f0a6e02-0b28-4a0b-9a63-1f6f6a2b1c11",
"website_id": "b7d0b5c5-3a2e-4f3a-9c1e-5c9f0b7a1d22",
"placement_id": "6e1b3f9a-8c4d-4a11-9f0e-2f7a5b6c8d99",
"content_ref": "photo_10422",
"nonce": "dl_7f3c2a91",
"market_tier": "premium",
"video_duration": 30,
"watched_seconds": 30,
"completed_at": "2026-02-18T11:04:22.118Z",
"expires_at": "2026-02-18T11:14:22.118Z",
"redeemed_at": "2026-02-18T11:04:23.902Z"
}
}
// Every other outcome is an error with its own code:
// 401 unauthorised missing or invalid API key
// 403 insufficient_scope key lacks unlocks:verify
// 400 receipt_invalid bad signature or not a receipt
// 410 receipt_expired older than 10 minutes
// 404 receipt_not_found unknown receipt
// 403 receipt_foreign issued for another website
// 409 receipt_already_redeemed used before (details include impression_id)
// 409 content_ref_mismatch receipt belongs to another item (not used up)
// 409 nonce_mismatch receipt belongs to another attempt (not used up)Supplying content_ref is recommended: verification fails if it does not match the receipt, which stops a receipt earned on a cheap item being replayed against an expensive one.
// Node / Deno example: your own /api/unlock route.
// Use a server-only key that has just the unlocks:verify scope.
const res = await fetch('https://gxztgogbzrpnzfceujno.supabase.co/functions/v1/publisher-api/v1/unlocks/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ADGROVE_UNLOCK_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
receipt: body.receipt,
content_ref: assetRefFromYourDatabase, // never trust the browser's value
nonce: nonceYouIssuedForThisAttempt, // optional, recommended
}),
});
if (res.status !== 200) {
// Redemption is atomic: two simultaneous calls cannot both succeed.
return new Response('Unlock rejected', { status: 403 });
}
// Genuine, fully watched, used once: grant the download.
return Response.json({ downloadUrl: await signDownload(assetRefFromYourDatabase) });
// Notes
// - Put nonce="dl_7f3c2a91" on the <adgrove-gate> element alongside ref.
// - On slow networks the unlock event may carry receipt: null; listen for
// the 'adgrove-receipt' event on document, which delivers it shortly after.
// - If no ad was available, the unlock event has adWatched: false and no
// receipt. Decide your own policy (for example a limited "download anyway").A completed unlock can also be re-read at any time with GET /v1/unlocks/{impression_id}, which returns the same object without changing redemption state.
If you would rather not depend on the browser at all, register a postback URL. AdGrove POSTs an unlock.completed event the moment an impression is recorded, retries with exponential backoff on failure, and pauses an endpoint that keeps rejecting deliveries.
POST https://your-site.example/adgrove/postback
X-AdGrove-Event: unlock.completed
X-AdGrove-Delivery: 8f5f2a3e-06f0-4b95-9c47-1b1cf7ee3f5c
X-AdGrove-Idempotency-Key: unlock-0f0a6e02-0b28-4a0b-9a63-1f6f6a2b1c11
X-AdGrove-Signature: t=1771412662,v1=9c1b...c7f2
{
"event": "unlock.completed",
"created_at": "2026-02-18T11:04:22.118Z",
"data": {
"impression_id": "0f0a6e02-0b28-4a0b-9a63-1f6f6a2b1c11",
"website_id": "b7d0b5c5-3a2e-4f3a-9c1e-5c9f0b7a1d22",
"placement_id": "6e1b3f9a-8c4d-4a11-9f0e-2f7a5b6c8d99",
"content_ref": "photo_10422",
"market_tier": "premium",
"video_duration": 30,
"watched_seconds": 30,
"completed_at": "2026-02-18T11:04:22.118Z"
}
}Every delivery is signed with your postback secret over {timestamp}.{raw body}. Verify it before trusting the payload, and de-duplicate on X-AdGrove-Idempotency-Key so a retry cannot double-credit.
import crypto from 'node:crypto';
export function isValidAdgroveSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Reject anything older than five minutes to stop replays.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1, 'hex'),
);
}Earnings returned by the API are authoritative: they reflect the amounts AdGrove has actually credited, after fraud adjustments and any Ghost Ad overrides. Do not recompute revenue from impression counts and a CPM; the two will diverge.
# Impressions and verified earnings for one piece of content
GET https://gxztgogbzrpnzfceujno.supabase.co/functions/v1/publisher-api/v1/refs/photo_10422?from=2026-02-01&to=2026-02-18
Authorization: Bearer ag_api_live_xxxxxxxxxxxxxxxxxxxx
# Daily totals across the whole website
GET https://gxztgogbzrpnzfceujno.supabase.co/functions/v1/publisher-api/v1/earnings?granularity=day&from=2026-02-01
Authorization: Bearer ag_api_live_xxxxxxxxxxxxxxxxxxxxGET /v1/refs lists your content references with impressions and verified earnings.GET /v1/refs/{content_ref} drills into one item.GET /v1/earnings returns totals by day, week or month.POST /v1/unlocks/verify and GET /v1/unlocks/{id} handle completions.Errors use conventional HTTP status codes with a machine-readable body.
{
"error": {
"code": "receipt_expired",
"message": "This receipt has expired"
}
}| Code | Meaning |
|---|---|
| unauthorised | Missing, revoked or unknown API key. |
| forbidden_scope | The key lacks the scope for this endpoint. Verification needs unlocks:verify. |
| receipt_invalid | Signature failed, or the receipt is not an AdGrove receipt. |
| receipt_expired | Older than five minutes: ask the viewer to watch again. |
| content_ref_mismatch | The receipt was issued for a different content reference. |
| rate_limited | Above your per-minute allowance. Honour the Retry-After header. |
Need a key, a postback endpoint or a higher rate limit? Get in touch and we will set it up on your account.