Key takeaways
- Sign the offerwall link in a Nuxt server route with private runtimeConfig keys, never under runtimeConfig.public or in a VITE_ variable, and fetch it with useFetch so SSR forwards the session cookie.
- Put a bare data-sharklio-open on your own link, and never bind it to false: Vue 3 renders "false", which the embed script treats as the link, so the click silently does nothing.
- Credit coins only in a postback server route that checks the HMAC with timingSafeEqual and lets the database apply each transaction ID once.
How do you add an offerwall to a Vue or Nuxt app?
Build the signed offerwall link in a Nuxt server route (or in your own API for a plain Vue app), fetch only the finished URL into your component, and show it as a link, an iframe, or a pop-up opened by the Sharklio embed script. Then receive the postback in a second Nuxt server route that recomputes the HMAC-SHA256 signature, compares it in constant time, and credits each transaction ID only once. The link hash salt and the postback secret key stay in server-only runtime config, and Vue never adds currency on its own.
There is no dedicated Vue or Nuxt SDK, and a web app does not need one. This guide is the Vue counterpart of How to add an offerwall to a Next.js or React app, plus one Vue attribute trap React does not have. All code was built and run against Nuxt 4.6.3 and Vue 3.5.44 on October 29, 2026.
Who does what
| Part | Does | Never does |
|---|---|---|
| Nuxt server routes (Nitro), or your API | Signs the link for the signed-in user, verifies postbacks, owns the balance | Signs a link for a user ID that came from the browser |
| Vue components and pages | Show the link, iframe, or button, and reload the balance | Hold the salt or the postback key, add coins |
| Sharklio | Shows offers for the user’s country and device, tracks results, sends a signed postback for every credit and every reversal | Pays your users directly |
From your app’s Integration tab you need the app ID, the link hash salt (signs links) and the postback secret key (signs postbacks). No app yet? Start with How to create your first app. Coming from Next.js, the pieces map like this:
| Job | Next.js | Nuxt 4 |
|---|---|---|
| Keep secrets on the server | No NEXT_PUBLIC_ prefix, server-only | Private runtimeConfig keys, never under public |
| Hand the link to the page | Server Component calls a function | server/api route plus useFetch |
| Load embed.js | next/script | app.head in nuxt.config or useHead |
| Receive the postback | app/api/.../route.ts | server/api/.../postback.get.ts |
| Refresh the balance | router.refresh() | refreshNuxtData() |
| Code that runs before every request | proxy (formerly middleware) | server/middleware |
Step 1: sign the link in a Nuxt server route
The Sharklio Offerwall link docs define the format:
https://wall.sharklio.com/{APP_ID}?user_id={USER_ID}&hash={HASH}
The hash is the HMAC-SHA256 of the user ID with your link hash salt, as lowercase hex. The user ID is 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -, must never change for the same user, and should be your database ID, not an email or a username. You can add up to five sub IDs (sub1 to sub5, 1 to 100 characters from A-Z a-z 0-9 . _ ~ : @ + -) to see which placement earns. They are not part of the hash, so use them for tracking only. If HMAC is new to you, What is HMAC? explains it in plain terms.
First declare the three values as private runtime config, with empty defaults:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
sharklioAppId: '',
sharklioLinkSalt: '',
sharklioPostbackKey: '',
},
})
Then set them on the server as NUXT_SHARKLIO_APP_ID, NUXT_SHARKLIO_LINK_SALT and NUXT_SHARKLIO_POSTBACK_KEY. The signing code goes in server/utils, which Nuxt auto-imports into server routes:
// server/utils/sharklio.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { H3Event } from 'h3'
const USER_ID = /^[A-Za-z0-9._@:+-]{1,100}$/
const SUB_VALUE = /^[A-Za-z0-9._~:@+-]{1,100}$/
type SubKey = 'sub1' | 'sub2' | 'sub3' | 'sub4' | 'sub5'
function secret(value: unknown, name: string): string {
if (typeof value !== 'string' || !value) throw new Error(`${name} is not set`)
return value
}
export function wallUrl(event: H3Event, userId: string | number, subs: Partial<Record<SubKey, string>> = {}): string {
const config = useRuntimeConfig(event)
const id = String(userId)
if (!USER_ID.test(id)) throw new Error('user ID not accepted by the offerwall')
const hash = createHmac('sha256', secret(config.sharklioLinkSalt, 'NUXT_SHARKLIO_LINK_SALT')).update(id).digest('hex')
let url = `https://wall.sharklio.com/${secret(config.sharklioAppId, 'NUXT_SHARKLIO_APP_ID')}?user_id=${encodeURIComponent(id)}&hash=${hash}`
for (const [key, value] of Object.entries(subs)) {
if (value && SUB_VALUE.test(value)) url += `&${key}=${encodeURIComponent(value)}`
}
return url
}
A small route returns the link for whoever is signed in. The .get.ts suffix limits it to GET requests:
// server/api/offerwall-link.get.ts
export default defineEventHandler(async (event) => {
const user = await getCurrentUser(event)
if (!user) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const { placement } = getQuery(event)
setResponseHeader(event, 'Cache-Control', 'private, no-store')
return { url: wallUrl(event, user.id, { sub1: typeof placement === 'string' ? placement : undefined }) }
})
getCurrentUser stands for your auth library’s session lookup. The user comes from the session cookie, never from a query parameter, and no-store stops any cache from giving one user’s link to another. In our test, the hash for user 42 matched one computed separately in Python. Four details keep the secret where it belongs:
- Private keys only. Nuxt’s runtime config guide says keys directly under
runtimeConfigare server-only, while keys underruntimeConfig.publicare also sent to the client. Never put the salt underpublicor name itNUXT_PUBLIC_SHARKLIO_LINK_SALT. Environment variables only override keys you declared, hence the empty defaults. - No .env in production. The same guide says a built server does not read
.env. Set the real variables on your host, and passeventtouseRuntimeConfigso runtime values are picked up. - No server imports in pages. Nuxt did not stop us from importing
server/utils/sharklio.tsinto a page; the build only warned aboutnode:crypto. The salt still stayed out of the bundle, since private config never reaches the browser, but keep these imports out ofapp/. - Keys read lazily.
secret()runs when a link is built, sonuxt buildworks without the secrets, and a server missing a key answers 500 with a clear log line instead of signing with an empty string.
On the Vue side, one composable fetches the link. Nuxt’s data fetching guide says that during server rendering, useFetch with a relative URL proxies the user’s headers and cookies to your API, while a bare $fetch does not. That is why this uses useFetch:
// app/composables/useOfferwallLink.ts
export function useOfferwallLink(placement: string) {
return useFetch('/api/offerwall-link', {
query: { placement },
key: `offerwall-link-${placement}`,
})
}
Step 2: choose how to show the wall
One app can use all of these at once, with the same keys and postback URL. The trade-offs are in Offerwall integration: iframe, link or API?
| Option | Vue code you write | Where it works | Best for |
|---|---|---|---|
| Plain link | An <a :href> with the signed URL | Anywhere, including localhost and WebViews | Menus, mobile WebViews, Capacitor apps |
| Iframe on an Earn page | One small component | https pages of your registered website and its subdomains | Rewards sites where the wall is the main product |
| Floating Earn button | One useHead call in a layout | Same as the iframe | Sites where sign-in and sign-out reload the page |
| Your own button with the pop-up | A link with data-sharklio-open, or Sharklio.open(url) | Same as the iframe; falls back to the plain link | SPAs and apps where the user can change without a page load |
| Offers API | Your own offer list UI | Anywhere | A fully native look; see Build a custom offerwall with the Offers API |
The iframe component
<!-- app/components/OfferwallFrame.vue -->
<script setup lang="ts">
const { src, title = 'Offerwall', height = '100vh' } = defineProps<{
src: string
title?: string
height?: string
}>()
</script>
<template>
<iframe :key="src" :title="title" :src="src" :style="{ border: '0', width: '100%', height }" />
</template>
<!-- app/pages/earn.vue -->
<script setup lang="ts">
const { data, error } = await useOfferwallLink('earn_page')
if (error.value?.statusCode === 401) await navigateTo('/login')
useRefreshOnReturn(['balance'])
</script>
<template>
<main>
<h1>Earn coins</h1>
<p>Coins usually arrive a few minutes after an offer is confirmed.</p>
<OfferwallFrame v-if="data?.url" :src="data.url" height="calc(100vh - 64px)" />
</main>
</template>
Server-rendered, the page came out with the signed link already in the iframe, and a guest got a 302 to /login. Keep the inline style: without it, browsers draw the frame as a 300 by 150 pixel box. The :key makes Vue replace the frame if the link changes, and useRefreshOnReturn comes in Step 5. Never prerender or cache /earn or the link route with routeRules: each user needs their own link.
If you send a Content Security Policy, allow https://wall.sharklio.com in frame-src for the iframe and the pop-up, and in script-src for the embed script.
The floating Earn button with useHead
The embed script draws a button in the corner and opens the wall in a pop-up over your page. In Nuxt, add it from your default layout so it only appears for signed-in users:
<!-- app/layouts/default.vue -->
<script setup lang="ts">
const { data } = await useOfferwallLink('button')
useHead(() => ({
script: data.value?.url
? [{
key: 'sharklio-button',
src: 'https://wall.sharklio.com/embed.js',
async: true,
'data-button-url': data.value.url,
'data-button-text': 'Earn coins',
'data-button-position': 'right',
}]
: [],
}))
</script>
<template>
<div><slot /></div>
</template>
In headless Chrome, the button appeared with the signed link and stayed a single button across client-side navigation. One limit matters: the button reads its link once, when the script runs. When we switched to another user without a page load, Nuxt updated the script tag, but the button still opened the first user’s wall until a full reload. Use it only when sign-in and sign-out reload the page (for example window.location.assign('/')), or use your own button below. The floating Earn button guide covers labels, colors and placement.
Your own button, and the Vue attribute trap
The embed script also opens the pop-up for any link that carries data-sharklio-open, and it listens for clicks on the whole document, so links Vue renders later work too. Load the script once for the whole app:
// nuxt.config.ts (add to the config above)
app: {
head: {
script: [{ src: 'https://wall.sharklio.com/embed.js', async: true }],
},
},
In a plain Vue app, put the same <script> tag in index.html. Then:
<!-- app/components/EarnLink.vue -->
<script setup lang="ts">
defineProps<{ href: string }>()
</script>
<template>
<a :href="href" data-sharklio-open>Earn coins</a>
</template>
A bare attribute in a Vue template is rendered as is, and the script opens the link’s href. The trap comes when you bind it:
Never bind data-sharklio-open to false
The Vue 3 attribute coercion notes say that for non-boolean attributes, Vue no longer removes the attribute when the value is false: it renders ="false". A data- attribute is never boolean, so :data-sharklio-open="isEnabled" renders data-sharklio-open="false" when the flag is off. The embed script reads any value other than empty or "true" as the link to open, so in our Chrome test the click was swallowed and nothing opened, with only a console warning. Bind null instead: Vue’s template syntax guide says null and undefined remove the attribute, so the link opens as a normal page. Write :data-sharklio-open="usePopup ? '' : null".
Unlike JSX, Vue does not turn a bare attribute into "true"; a bound true renders "true", which the script also accepts. A NuxtLink with external passes the attribute to its <a> and worked too. For a click handler, for example a “Not enough coins?” button, call the documented Sharklio.open(link) with a fallback for before the script loads:
// app/utils/openOfferwall.ts
declare global {
interface Window {
Sharklio?: { open(link: string): void }
}
}
export function openOfferwall(url: string) {
if (window.Sharklio?.open) {
window.Sharklio.open(url)
} else {
window.location.href = url
}
}
Use it as @click="openOfferwall(data.url)", only from a user’s click. Opening the wall on page load, on a timer, or on scroll breaks the placement rules.
Step 3: a plain Vue app (Vite)
Without Nuxt, the browser asks your backend for the link; the Express version is in Offerwall postback in Node.js, Express and Next.js. Never sign in the Vue app itself: a VITE_ variable is compiled into the JavaScript users download. This composable fetches the link whenever the signed-in user changes:
// src/composables/useOfferwallLink.js
import { ref, watch } from 'vue'
export function useOfferwallLink(userId) {
const url = ref(null)
watch(userId, (id, _old, onCleanup) => {
url.value = null
if (!id) return
let ignore = false
onCleanup(() => {
ignore = true
})
fetch('/api/offerwall-link?placement=spa', { credentials: 'same-origin', cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const link = data?.url
if (!ignore && typeof link === 'string' && link.startsWith('https://wall.sharklio.com/')) {
url.value = link
}
})
.catch(() => {})
}, { immediate: true })
return url
}
Vue’s watchers guide describes the problem: if the ID changes before a request completes, the old request still finishes with a stale value. onCleanup, the third argument of the watch callback, marks it as ignored, and clearing url first means one user’s link is never shown to the next. With a slow response for user 42 and a fast one for 43, it ended on 43’s link. Pass a ref, for example useOfferwallLink(computed(() => auth.user?.id ?? null)), and render the result with EarnLink or OfferwallFrame.
Step 4: the postback server route
Rewards never travel through Vue. The Postbacks docs define the signature: HMAC-SHA256 of transaction_id:user_id:reward:status with your postback secret key, as lowercase hex, using the values exactly as they arrive. Register this URL in the Postback tab:
https://yoursite.com/api/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
Add the reading and checking functions to server/utils/sharklio.ts. The query object from getQuery turns a repeated key into an array, so anything that is not exactly one string is rejected:
// server/utils/sharklio.ts (continued)
export type Postback = { tx: string; user: string; reward: string; status: string; hash: string }
const KEYS = ['tx', 'user', 'reward', 'status', 'hash'] as const
export function readPostback(query: Record<string, unknown>): Postback | null {
const p = {} as Postback
for (const key of KEYS) {
const value = query[key]
if (typeof value !== 'string') return null
p[key] = value
}
return p
}
export function validHash(event: H3Event, p: Postback): boolean {
const key = secret(useRuntimeConfig(event).sharklioPostbackKey, 'NUXT_SHARKLIO_POSTBACK_KEY')
const expected = Buffer.from(
createHmac('sha256', key).update(`${p.tx}:${p.user}:${p.reward}:${p.status}`).digest('hex')
)
const given = Buffer.from(p.hash)
return given.length === expected.length && timingSafeEqual(given, expected)
}
Nuxt’s server directory guide says to use setResponseStatus for other status codes, so the route stays short:
// server/api/sharklio/postback.get.ts
export default defineEventHandler(async (event) => {
try {
const p = readPostback(getQuery(event))
if (!p || !validHash(event, p)) {
setResponseStatus(event, 403)
return ''
}
if (p.tx.startsWith('test_')) {
console.info('sharklio: test postback', p.tx, p.status)
return ''
}
await applyEvent(p)
return ''
} catch (err) {
console.error('sharklio: postback failed', err)
setResponseStatus(event, 500)
return ''
}
})
Crediting each transaction once
A retry can deliver the same event twice, and a reversal (status 2) carries the same transaction_id and the same positive reward as the credit it cancels. This is the same PostgreSQL logic as the Next.js guide, where an ON CONFLICT DO NOTHING insert on the primary key decides whether a credit is new. It assumes the sharklio_tx table from the Node.js guide and a pg pool exported from server/utils/db.ts (export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })):
// server/utils/credit.ts
const INSERT = `INSERT INTO sharklio_tx (transaction_id, user_id, reward, last_status)
VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO NOTHING`
export async function applyEvent(p: Postback): Promise<void> {
const client = await pool.connect()
try {
await client.query('BEGIN')
if (p.status === '1') {
const created = await client.query(INSERT, [p.tx, p.user, p.reward, 1])
if (created.rowCount === 1) {
const r = await client.query('UPDATE users SET coins = coins + $1::numeric WHERE id = $2', [p.reward, p.user])
if (r.rowCount === 0) console.warn('sharklio: unknown user', p.user, p.tx)
}
} else if (p.status === '2') {
const reversed = await client.query(
`UPDATE sharklio_tx SET last_status = 2, updated_at = now()
WHERE transaction_id = $1 AND last_status = 1 RETURNING user_id, reward`,
[p.tx]
)
if (reversed.rowCount === 1) {
const { user_id, reward } = reversed.rows[0]
await client.query('UPDATE users SET coins = coins - $1 WHERE id = $2', [reward, user_id])
} else {
await client.query(INSERT, [p.tx, p.user, p.reward, 2])
}
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
We ran the built Nuxt server against a real PostgreSQL engine (PGlite) and sent signed requests with curl: a credit sent twice paid once, a reversal sent twice took the coins back once, a reversal for a never-credited transaction blocked a late credit, and a test_ transaction was only logged. A wrong hash, an uppercase hash, a repeated tx and no parameters all got 403. The design choices and other databases are in How to prevent duplicate reward credits.
Nuxt details that break postbacks:
- Server middleware. The server directory guide says
server/middlewareruns on every request before any route. If yours checks sessions or redirects guests, skip the postback path: Sharklio does not follow redirects. - Static builds. Nuxt’s deployment guide describes
nuxt generateoutput as prerendered files. A site deployed only that way has no server to sign links or receive postbacks, so use a Node.js or serverless preset, or run those two routes on a separate backend. - Six seconds. Answer any 2xx within 6 seconds, or the event is retried up to 5 times. Serverless cold starts eat into that, so use a connection pooler and send emails after the commit.
- Status codes. 200 for anything applied or deliberately skipped, 403 for a bad hash, 500 for your own failures so the event comes back later.
Why each check exists is explained in Postback security: verify every reward call. If your firewall only admits known addresses, see Postback IP allowlist, and still verify the hash.
Step 5: show the new balance in Vue
Credits arrive when the advertiser confirms a result, sometimes within minutes, sometimes later, so Vue should read the balance from your server. If your header loads it with useFetch('/api/balance', { key: 'balance' }), this composable refreshes it whenever the user comes back to the tab:
// app/composables/useRefreshOnReturn.ts
export function useRefreshOnReturn(keys: string[]) {
function onVisible() {
if (document.visibilityState === 'visible') refreshNuxtData(keys)
}
onMounted(() => document.addEventListener('visibilitychange', onVisible))
onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisible))
}
In our test, a credit posted while the tab was hidden showed in the header as soon as the tab became visible again. In a plain Vue app, refetch on the same event. Users follow pending offers in My offers inside the wall and can report a missing reward there. If you price items in coins, the virtual currency calculator helps you set the rate.
Test it on localhost and before going live
The iframe and the pop-up only load on https pages of the website you registered for the app and its subdomains, so on http://localhost:3000 they stay blank. That is expected, not a bug. While you develop:
- Open the signed link itself in a new tab (it works anywhere), or click Open as a test user in Integration status.
- In Check a link, type the user ID your code signs into Test a link: the two links must be identical.
- Test the pop-up and iframe on an https staging subdomain of your registered site.
- Test the postback route with a hash you compute yourself (sample values only):
Run it twice and user 42 gains 150 coins once.TX=local_1 U=42 R=150 S=1 HASH=$(node -e "console.log(require('node:crypto').createHmac('sha256', process.env.NUXT_SHARKLIO_POSTBACK_KEY).update(process.argv[1]).digest('hex'))" "$TX:$U:$R:$S") curl -i "http://localhost:3000/api/sharklio/postback?tx=$TX&user=$U&reward=$R&status=$S&hash=$HASH" - Click Send a test postback once with status 1 and once with status 2. Test transaction IDs start with
test_, which the route logs without crediting. - Test from a normal home or mobile connection: VPNs, proxies, Tor and data center networks see a block page instead of offers.
The full checklist is in How to test your app before it goes live and the Sharklio Testing docs. After launch, every postback attempt is in Reports, Postbacks; see How to resend a failed postback. Wrapping the same Vue app for phones? Ionic and Capacitor offerwall covers opening the link outside the browser.
A quick review before you deploy
Search .output (or dist for Vite) and a page’s HTML source for the first characters of your salt and postback key: there must be no match. Confirm the postback path answers 403, not a redirect, when opened without parameters. If a key ever leaks, follow How to rotate your keys and link salt.
Sharklio in a Vue or Nuxt app
Sharklio is a pay-per-result affiliate network: your users earn your own currency by completing offers, and you earn per completed offer. A Vue or Nuxt site uses the signed offerwall link or iframe, the floating Earn button (embed.js), or the Offers API, and every credit and reversal reaches your server as a signed postback. Each app sets its currency name and icon, conversion rate, user split, rounding, and wall design with a live preview, and every publisher application is reviewed by hand. See how the wall works, or get in touch.
Frequently asked questions
Is there a Vue or Nuxt SDK for the offerwall?
No, and a web app does not need one. Sign the link in a server route, show it as a link, an iframe or the embed.js pop-up, and credit users from verified postbacks.
Can I build the offerwall link in a Vue component?
No. Anything in client JavaScript, including a VITE_ variable or a runtimeConfig.public key, can be read by users, and the salt would let anyone open the wall as any user ID.
Why does my data-sharklio-open link do nothing in Vue?
Usually because it is bound to false, which Vue 3 renders as data-sharklio-open="false", and the embed script treats that value as the link. Bind null to remove the attribute, and confirm the script loaded.
How do I receive an offerwall postback in Nuxt?
Create server/api/sharklio/postback.get.ts, read each query value as exactly one string, verify the HMAC-SHA256 with timingSafeEqual, and credit each transaction ID once inside a database transaction. Make sure no server middleware redirects that path.
Does the offerwall work with nuxt generate?
Only with a server somewhere. A purely static build cannot sign links or receive postbacks, so deploy Nuxt with a server or serverless preset, or point the two routes at a separate backend.



