Key takeaways
- Put the link signing, the hash check and the crediting in one server-only module, and keep the Express route and the Next.js route handler as thin wrappers around it.
- Read every parameter as exactly one string, hash it unchanged, and compare Buffers of equal byte length with crypto.timingSafeEqual before you touch the database.
- Lock one row per transaction ID in a database transaction, credit on status 1, take back on status 2 only after a credit, and answer 200 for repeats and 500 for your own errors.
How do you handle an offerwall postback in Node.js?
Expose one GET route that reads the transaction ID, user ID, reward, status and hash as plain strings, recomputes the HMAC-SHA256 of tx:user:reward:status with your postback secret key, and compares it with crypto.timingSafeEqual after a length check. If it matches, apply the event inside a database transaction that locks one row per transaction ID: add the reward on status 1, take it back on status 2 only if you credited it, and skip repeats. Answer 200 for everything you accepted or ignored, 403 for a bad hash, and 500 when your own code fails, so the call is retried.
This guide gives you that as one small module you can share between an Express app and a Next.js App Router project, plus the signed offerwall link. It assumes you know why each check exists. If you do not, read Postback security first, and What is a postback URL? for the basics. The PHP version is in Offerwall PHP integration: Laravel and plain PHP.
Who does what
| Part | Does | Never does |
|---|---|---|
| Browser or app | Shows the signed link it got from your server | Holds the link hash salt or the postback secret key, adds currency |
| Your Node.js server | Signs the link, verifies postbacks, owns the wallet table | Trusts a balance change that did not come from a verified postback |
| Sharklio | Shows offers, tracks results, sends a signed postback for every credit and every reversal | Pays your users directly |
You need three values from your app’s Integration tab: the app ID in your offerwall link, the link hash salt, and the postback secret key. The salt signs links and the key signs postbacks. They are different values, and mixing them up is the most common reason for a Link not valid page or a 403 on every postback.
Step 1: one shared module
Both frameworks call the same file, so the security logic exists once. It uses node:crypto and the pg client for PostgreSQL. Store the keys in environment variables (SHARKLIO_APP_ID, SHARKLIO_LINK_SALT, SHARKLIO_POSTBACK_KEY, DATABASE_URL) and keep the .env file out of your repository.
First the table. One row per transaction ID, with the last status you applied:
CREATE TABLE sharklio_tx (
transaction_id text PRIMARY KEY,
user_id text NOT NULL,
reward numeric NOT NULL,
last_status smallint,
updated_at timestamptz NOT NULL DEFAULT now()
);
Then the module. The examples credit a coins column of type numeric on your users table:
// lib/sharklio.js (ES module)
import crypto from 'node:crypto';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const KEYS = ['tx', 'user', 'reward', 'status', 'hash'];
function env(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
}
export function wallUrl(userId) {
const id = String(userId);
const hash = crypto.createHmac('sha256', env('SHARKLIO_LINK_SALT')).update(id).digest('hex');
return `https://wall.sharklio.com/${env('SHARKLIO_APP_ID')}?user_id=${encodeURIComponent(id)}&hash=${hash}`;
}
export function readParams(get) {
const p = {};
for (const key of KEYS) {
const value = get(key);
if (typeof value !== 'string') return null;
p[key] = value;
}
return p;
}
function validHash(p) {
const expected = Buffer.from(
crypto.createHmac('sha256', env('SHARKLIO_POSTBACK_KEY'))
.update(`${p.tx}:${p.user}:${p.reward}:${p.status}`)
.digest('hex')
);
const given = Buffer.from(p.hash);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
async function applyEvent(p) {
const status = Number(p.status);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'INSERT INTO sharklio_tx (transaction_id, user_id, reward) VALUES ($1, $2, $3) ON CONFLICT (transaction_id) DO NOTHING',
[p.tx, p.user, p.reward]
);
const { rows } = await client.query(
'SELECT user_id, reward, last_status FROM sharklio_tx WHERE transaction_id = $1 FOR UPDATE',
[p.tx]
);
const row = rows[0];
if (status === 1 && row.last_status === null) {
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);
await client.query('UPDATE sharklio_tx SET last_status = 1, updated_at = now() WHERE transaction_id = $1', [p.tx]);
} else if (status === 2 && row.last_status === 1) {
await client.query('UPDATE users SET coins = coins - $1 WHERE id = $2', [row.reward, row.user_id]);
await client.query('UPDATE sharklio_tx SET last_status = 2, updated_at = now() WHERE transaction_id = $1', [p.tx]);
} else if (status === 2 && row.last_status === null) {
await client.query('UPDATE sharklio_tx SET last_status = 2, updated_at = now() WHERE transaction_id = $1', [p.tx]);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function handlePostback(p) {
if (!p || !validHash(p)) return 403;
if (p.tx.startsWith('test_')) {
console.info('sharklio: test postback', p.tx, p.status);
return 200;
}
await applyEvent(p);
return 200;
}
Why it is written this way
- One string per parameter.
readParamstakes a getter, so each framework decides how to read the query, and anything that is not exactly one string (missing, or an array from a repeated parameter) is rejected with 403 before hashing. - Byte length, not string length. The Node.js docs say
timingSafeEqualthrows if the two inputs have different byte lengths. A hash with non-ASCII characters can have the right number of characters and still a different number of bytes, so the check compares the two Buffers, and a forged hash gets a 403 instead of an exception. - Values hashed unchanged. The reward goes into the HMAC exactly as it arrived. Convert nothing before the check.
- No floats. The reward goes from the query string to PostgreSQL as text and is added as
numeric. Parsing it withNumber()first would push a decimal reward through binary floating point for no reason. - One client for the whole transaction. The node-postgres docs say you must use the same client instance for every statement in a transaction and must not use
pool.queryfor it. That is why the code callspool.connect()and releases the client infinally. - The row lock does the idempotency.
ON CONFLICT DO NOTHINGcreates the row on the first event, andFOR UPDATEmakes a second copy of the same event wait until the first commits. It then seeslast_statusalready set and changes nothing. - Reversals use the stored values. Status 2 takes back the reward saved with the credit, from the user it went to. A reversal for a transaction you never credited is recorded as 2, so a late copy of that credit, for example one you resend by hand, cannot pay afterwards.
- Lazy key checks.
env()throws when a key is missing, so a server without its key answers 500 and the event is retried, instead of hashing with an empty secret. Reading the keys inside the functions, not at import time, also keepsnext buildfrom failing on a build machine that has no secrets.
Test postbacks sent from the dashboard always have a transaction ID that starts with test_ and offer_id 0. The module checks their hash and logs them without crediting anyone. Remove that branch if you prefer to credit a test user and take the coins back by hand.
Step 2: the Express route
With the module in place, the Express side is two short routes: one that hands a signed link to your logged-in front end, and one that receives postbacks.
// server.js (Express 5, "type": "module" in package.json)
import express from 'express';
import { wallUrl, readParams, handlePostback } from './lib/sharklio.js';
const app = express();
app.get('/api/offerwall-link', requireLogin, (req, res) => {
res.set('Cache-Control', 'private, no-store');
res.json({ url: wallUrl(req.user.id) });
});
app.get('/sharklio/postback', async (req, res) => {
const status = await handlePostback(readParams((key) => req.query[key]));
res.sendStatus(status);
});
app.listen(3000);
requireLoginstands for your own session middleware. Your React, Vue or mobile front end calls/api/offerwall-link, gets the URL, and puts it in an iframe or a WebView.no-storestops any cache from handing one user’s signed link to another.- The Express 5 migration guide says rejected promises in handlers are now passed to the error handler as if you called
next(err), and Express’s default error handler answers 500. So a database error duringhandlePostbackbecomes a 500, and Sharklio retries the event. On Express 4 you need atry/catchthat sends 500 yourself. - The same guide says Express 5 changed the default query parser from “extended” to “simple”. With either one,
?tx=a&tx=bgives you an array, which is whyreadParamschecks the type. - Keep the postback route outside anything built for browsers: login checks, CSRF middleware, per-IP rate limiters. Every postback comes from Sharklio’s servers, so a per-IP limit can block a busy day’s worth of genuine calls. The hash is what protects the route.
Set the postback URL in your app’s Postback tab with the macros the module reads, using the exact final address:
https://yoursite.com/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
You can add more macros for your logs, such as {event_id}, {offer_name}, {payout}, {sub1} or {integration}. Only the four signed values decide what you credit.
Step 3: the Next.js route handler
In the App Router, a route.ts file exports a function per HTTP method. The Next.js docs pass it a NextRequest and read query values with request.nextUrl.searchParams:
// app/api/sharklio/postback/route.ts
import type { NextRequest } from 'next/server';
import { readParams, handlePostback } from '@/lib/sharklio';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams;
const params = readParams((key) => (sp.getAll(key).length === 1 ? sp.get(key) : undefined));
try {
return new Response(null, { status: await handlePostback(params) });
} catch (err) {
console.error('sharklio postback failed', err);
return new Response(null, { status: 500 });
}
}
Notes specific to Next.js:
searchParams.get()returns only the first value of a repeated parameter, so the handler counts values withgetAll()and rejects anything that is not exactly one.- Pin the Node.js runtime. The module needs
node:cryptoand a TCP database connection.runtime = 'nodejs'is the default, and writing it down stops someone from switching the route to the Edge runtime later. - Caching. The Next.js version history says
GEThandlers changed from static to dynamic by default in version 15. If you are still on 14 or earlier, addexport const dynamic = 'force-dynamic'. - No redirects. By default Next.js redirects
/path/to/path, and withtrailingSlash: trueit does the opposite. Sharklio does not follow redirects, so register the path in the form your config serves, with no redirect in between. - Proxy (formerly middleware). In Next.js 16 the
middlewarefile was renamedproxy, and without a matcher it runs on every request. If it redirects signed-out visitors to a login page, exclude/api/sharklio/postbackfrom its matcher, or every postback gets a redirect.
The signed link in a Server Component
Pages in the App Router are Server Components by default, so they can sign the link directly. Next.js only puts environment variables that start with NEXT_PUBLIC_ into the browser bundle, so never give the salt that prefix.
// app/earn/page.tsx
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { wallUrl } from '@/lib/sharklio';
export default async function EarnPage() {
const user = await getCurrentUser();
if (!user) redirect('/login');
return (
<main>
<h1>Earn coins</h1>
<iframe
title="Earn coins"
src={wallUrl(user.id)}
style={{ border: 0, width: '100%', height: 'calc(100vh - 64px)' }}
/>
</main>
);
}
getCurrentUser stands for your auth library’s session lookup. When it reads the session cookie, Next.js renders the page per request, so every user gets their own link. In a Next.js-only project, add import 'server-only' at the top of lib/sharklio.js, and the Next.js docs say importing it into a Client Component then fails at build time. Leave it out if Express shares the file: outside the Next.js server bundle, the package throws as soon as it is imported.
The iframe only loads on https pages of the website you registered for the app and its subdomains. If you send a Content Security Policy, allow https://wall.sharklio.com in frame-src. To open the wall in a pop-up from any page instead, see Add a floating Earn button to any website.
Express and Next.js side by side
| Concern | Express 5 | Next.js App Router |
|---|---|---|
| Postback route | app.get('/sharklio/postback', ...) | app/api/sharklio/postback/route.ts exporting GET |
| Reading a value | req.query.tx | request.nextUrl.searchParams.get('tx') |
| Repeated parameter | Becomes an array | get() returns the first value |
| Uncaught error | Default error handler answers 500 | Catch it and return 500 |
| Redirect traps | Your own http to https or www rules, reverse proxy | trailingSlash, proxy redirects |
| Where the link is signed | A JSON endpoint behind your login | A Server Component page |
Credits, reversals and retries
Every event of one result carries the same {transaction_id}, and there are only two statuses:
| Incoming status | Last status in your table | What the module does |
|---|---|---|
| 1 (credited) | none | Adds the reward, stores 1 |
| 1 (credited) | 1 or 2 | Nothing, answers 200 |
| 2 (reversed) | 1 | Takes back the stored reward, stores 2 |
| 2 (reversed) | none | Stores 2 so a late credit cannot pay, answers 200 |
| 2 (reversed) | 2 | Nothing, answers 200 |
Amounts are never negative: a reversal carries the same positive {reward} as the credit, and the status tells you to subtract. An offer with several steps sends each step as its own result with its own transaction ID, so each step is simply another row. If the user has already spent the coins, the module lets the balance go below zero. You can instead hold withdrawals until it is recovered, as long as your terms allow it. Publisher earnings and reversals covers the business side.
Sharklio waits up to 6 seconds for any 2xx answer and does not read the body. On a failure or timeout it tries again up to 5 times, after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, and events of one transaction arrive in order. Keep anything slow, such as emails or push notifications, out of the request: queue it after the commit. On a serverless host, a cold start plus a new database connection eats into those 6 seconds, so use your provider’s connection pooler. If the last retry fails too, you can send the postback again from your dashboard; see How to resend a failed postback.
Node.js mistakes that cost real coins
Comparing hashes with === instead of timingSafeEqual. Calling timingSafeEqual without a length check, so a short forged hash throws and your route answers 500 to an attacker. Running BEGIN through pool.query, so the lock and the update land on different connections. Answering 200 before the database commit, so a crash loses the credit with no retry. And crediting {payout} or another unsigned macro instead of the signed {reward}.
Step 4: test it before you go live
- In Integration status on the Integration tab, use Check a link and its Test a link tool. Type the same user ID your code uses and compare the two links. They must be identical.
- Click Send a test postback in Integration status and check that your server answered 200 and logged a
test_transaction. - Test the crediting path locally with a hash you compute yourself, with
SHARKLIO_POSTBACK_KEYexported in your shell and a test user 42 in your database. Sample values only (use/sharklio/postbackfor the Express app):
Run it twice: user 42 should gain 150 coins once. ChangeTX=local_1 U=42 R=150 S=1 HASH=$(node -e "const c=require('node:crypto');console.log(c.createHmac('sha256',process.env.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"Sto 2, recompute the hash, run it again, and the 150 coins come back off. - Change one character of the hash and run it again. You must get 403. Then add
&tx=xto the URL: also 403. - After going live, every attempt and your server’s status code is in Reports, Postbacks. The Testing and troubleshooting docs list the common errors.
The full launch checklist is in How to test your app before it goes live. If a key leaks, replace it and redeploy the new environment variable right away, as described in How to rotate your keys and link salt.
Reference docs
The exact macros, statuses and hash format are in the Sharklio Postbacks, Credits and reversals and Offerwall link docs. For the platform APIs used above, see the Node.js crypto.timingSafeEqual and crypto.createHmac reference, the Express routing guide and Express 5 migration guide, the Next.js route.js reference and proxy.js reference, and the node-postgres page on transactions (all checked on October 21, 2026). Working in Python instead? See Offerwall postback in Python. For why rewards travel server to server at all, read What is server-to-server tracking? and Postback vs pixel tracking.
One handler for every Sharklio integration
Sharklio is a pay-per-result affiliate network. Every app sends a signed server-to-server postback for every credit and every reversal, whether the user came through the offerwall link or iframe, the floating Earn button, the Offers API, or the Android, iOS, Unity, Flutter or React Native SDKs, so the module above serves all of them. Each app sets its own currency name, conversion rate, user split and rounding, and the {reward} arrives already converted. Every publisher application and every app is reviewed by hand before the wall goes live. See how the wall works, or get in touch.
Frequently asked questions
How do I verify a postback hash in Node.js?
Compute crypto.createHmac('sha256', key).update(`${tx}:${user}:${reward}:${status}`).digest('hex') with the values exactly as received. Turn both hashes into Buffers, check they have the same length, and compare them with crypto.timingSafeEqual.
Why does crypto.timingSafeEqual throw an error?
The two inputs have different byte lengths, which Node.js treats as an error rather than a mismatch. Compare the Buffer lengths first and return 403 when they differ.
Should the postback be an API route in Next.js?
Yes. Put it in a route.ts file under app/ that exports GET, keep it on the Node.js runtime, and make sure no proxy redirect or trailing slash redirect sits in front of it.
How do I stop a postback from crediting twice in Node.js?
Store the last status per transaction ID, and lock that row with SELECT ... FOR UPDATE inside a database transaction on a single client. A retry or a duplicate then finds the status already set and changes nothing.
What should a Node.js postback handler return?
Any 2xx within 6 seconds for events you applied or deliberately ignored, 403 for an invalid hash, and 500 when your own code or database fails, so the event is retried later.
Can I build the signed offerwall link in React?
Only on the server. Sign it in a Server Component or an API endpoint and send the finished URL to the browser, because anything in client JavaScript, including the salt, can be read by users.