Skip to documentation
AdQuestDocs

Complete example: Next.js

A session route, a widget with token refresh, and a signed webhook receiver for a Next.js app.

On this page

Prerequisites

  • A Next.js app with its own sign-in. getSignedInUser below stands for your existing session lookup.
  • The values from Keys and environments in .env.local, and the webhook signing secret as ADQUEST_WEBHOOK_SECRET.
  • npm install @adquest/sdk

Step 1: The session route

app/api/adquest/session/route.ts
export async function POST(request: Request) {
  const user = await getSignedInUser(request); // your own sign-in
  if (!user) return new Response(null, { status: 401 });
  const { locale } = (await request.json().catch(() => ({}))) as { locale?: string };

  const response = await fetch(process.env.ADQUEST_API_URL + "/platform/participant-sessions", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-AdQuest-Platform-Secret": process.env.ADQUEST_PLATFORM_SECRET! },
    body: JSON.stringify({ external_user_id: user.id, display_name: user.name, locale }),
    cache: "no-store",
  });
  const result = await response.json();
  if (!response.ok || !result.status) return new Response("Could not create an AdQuest session", { status: 502 });

  return Response.json(
    { externalUserId: user.id, participantToken: result.data.participant_token, expiresAt: result.data.expires_at },
    { headers: { "Cache-Control": "no-store" } },
  );
}

The route answers 401 when nobody is signed in, never returns the Secret Key, and tells the browser not to cache the token.

Step 2: The widget, with refresh

app/rewards/Rewards.tsx
"use client";
import { useCallback, useEffect, useState } from "react";
import { AdQuestWidget } from "@adquest/sdk/react";
import "@adquest/sdk/styles.css";

type Session = { externalUserId: string; participantToken: string; expiresAt: string };

export default function Rewards() {
  const [session, setSession] = useState<Session | null>(null);
  const [failed, setFailed] = useState(false);

  const refresh = useCallback(async () => {
    const response = await fetch("/api/adquest/session", { method: "POST", body: JSON.stringify({ locale: "en" }) });
    if (!response.ok) return setFailed(true);
    setFailed(false);
    setSession(await response.json());
  }, []);

  useEffect(() => { void refresh(); }, [refresh]);
  useEffect(() => {
    if (!session) return;
    const wait = Math.max(1000, Date.parse(session.expiresAt) - Date.now() - 60_000);
    const timer = setTimeout(() => void refresh(), wait);
    return () => clearTimeout(timer);
  }, [session, refresh]);

  if (failed) return <button onClick={() => void refresh()}>Retry</button>;
  if (!session) return <p>Loading rewards…</p>;
  return <AdQuestWidget
    key={session.externalUserId}
    apiKey={process.env.NEXT_PUBLIC_ADQUEST_PLATFORM_API_KEY!}
    baseUrl={process.env.NEXT_PUBLIC_ADQUEST_API_URL!}
    externalUserId={session.externalUserId}
    participantToken={session.participantToken}
    locale="en"
    onAuthRequired={() => void refresh()}
  />;
}

The token lasts 15 minutes, so the component asks for a new one a minute before it ends. The same locale goes to the session route and to the widget.

Step 3: The webhook receiver

app/api/adquest/webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string) {
  const match = /^t=(d+),v1=([0-9a-f]{64})$/.exec(header);
  if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false;
  const expected = createHmac("sha256", secret).update(match[1] + "." + rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(match[2]), Buffer.from(expected));
}

export async function POST(request: Request) {
  const rawBody = await request.text(); // before JSON parsing
  if (!verify(rawBody, request.headers.get("X-AdQuest-Signature-V2") ?? "", process.env.ADQUEST_WEBHOOK_SECRET!)) {
    return new Response("Invalid signature", { status: 401 });
  }
  const eventId = request.headers.get("X-AdQuest-Event-Id") ?? "";
  if (await alreadyProcessed(eventId)) return new Response("OK"); // duplicates are normal

  const event = JSON.parse(rawBody) as { external_user_id?: string; tokens?: number };
  if (request.headers.get("X-AdQuest-Event") === "quest.completed") {
    await grantInYourProduct(event.external_user_id ?? "", event.tokens ?? 0);
  }
  await markProcessed(eventId);
  return new Response("OK");
}

Check that it works

  • Sign in, open the page and confirm the widget shows quests and a token balance.
  • Complete a quest. The balance changes and your webhook receives quest.completed.
  • Replay the delivery from the dashboard. Your receiver answers OK and grants nothing a second time.

Next steps

Something unclear, missing or wrong here? Tell us