Developer docs

    Verified ad completions

    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.

    Overview

    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.

    1. Embed the player

    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.

    HTML
    <!-- 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.

    2. Listen for the unlock event

    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.

    JavaScript
    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.

    3. Verify the completion server-side

    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.

    Request
    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"
    }
    Response 200
    // 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.

    Your server
    // 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.

    Signed postbacks

    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.

    Delivery
    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.

    Verify a signature
    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'),
      );
    }

    Reporting endpoints

    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.

    Requests
    # 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_xxxxxxxxxxxxxxxxxxxx
    • GET /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, scopes and limits

    Errors use conventional HTTP status codes with a machine-readable body.

    Error shape
    {
      "error": {
        "code": "receipt_expired",
        "message": "This receipt has expired"
      }
    }
    CodeMeaning
    unauthorisedMissing, revoked or unknown API key.
    forbidden_scopeThe key lacks the scope for this endpoint. Verification needs unlocks:verify.
    receipt_invalidSignature failed, or the receipt is not an AdGrove receipt.
    receipt_expiredOlder than five minutes: ask the viewer to watch again.
    content_ref_mismatchThe receipt was issued for a different content reference.
    rate_limitedAbove 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.