Key takeaways

  • Use a 2nd gen onRequest function as the postback URL, keep the postback secret key in Secret Manager with defineSecret, and reject any request whose HMAC does not match before you touch Firestore.
  • Key a Firestore document by the transaction ID and write it with create() inside runTransaction, so retries and parallel copies credit the wallet once and a reversal only takes back what was credited.
  • Use the Firebase Auth UID as the offerwall user ID and sign the wall link or SDK session in a callable function, so no Sharklio secret ever ships inside your app.

How do you handle an offerwall postback with Firebase Cloud Functions?

Deploy a 2nd gen onRequest HTTP function as your postback URL, keep the postback secret key in Secret Manager with defineSecret, and check the HMAC-SHA256 of tx:user:reward:status before you touch Firestore. Then apply the event in a Firestore transaction keyed by the transaction ID: create() the transaction document and add the reward on status 1, take it back on status 2 only if you credited it, and answer 200 for repeats. The user ID is the Firebase Auth UID, and a callable function signs the offerwall link for the signed-in user, so no secret ever ships in your app.

Everything below was run in the Firebase Emulator Suite on October 22, 2026, with firebase-functions 7.4.0, firebase-admin 14.5.0, firebase-tools 15.34.1 and Node.js 22. For a plain Node.js server, read Offerwall postback in Node.js, Express and Next.js. For the general theory of crediting once, see How to prevent duplicate reward credits.

Who does what

PartDoesNever does
Your app (Flutter, Android, iOS or web)Signs the user in with Firebase Auth, asks a callable function for the signed link or SDK token, shows the balance from FirestoreHolds a Sharklio key, writes to the wallet
Callable functionReads the UID from the verified Auth token and signs the link, or asks Sharklio for a single-use SDK sessionTakes the user ID from the request body
Postback functionVerifies the hash, credits and reverses in a Firestore transactionTrusts an unsigned parameter, does slow work before it answers
FirestoreStores one document per transaction and one wallet per UIDLets a client write its own balance
SharklioTracks the offer and sends a signed postback for every credit and every reversalPays your users directly

From your app’s Integration tab you need the App ID, the link hash salt (signs links), the postback secret key (signs postbacks) and, for a mobile SDK, the API key (creates sessions). They are four different values. The Postbacks and Offerwall link docs define the formats used here.

Step 1: runtime, packages and secrets

Cloud Functions for Firebase only deploys on the pay-as-you-go Blaze plan, as the Cloud Functions FAQ says. Firebase’s manage functions page lists Node.js 22 and 20 as supported runtimes and Node.js 18 as deprecated (checked on October 22, 2026). Pick 22: firebase-admin 14 declares Node.js 22 or newer in its package.json.

In your functions/ folder:

npm install firebase-functions@7 firebase-admin@14

# functions/package.json
"main": "index.js",
"engines": { "node": "22" }

# the three secrets go to Cloud Secret Manager
firebase functions:secrets:set SHARKLIO_POSTBACK_KEY
firebase functions:secrets:set SHARKLIO_LINK_SALT
firebase functions:secrets:set SHARKLIO_API_KEY

# the App ID is not secret: a plain parameter in functions/.env
SHARKLIO_APP_ID=your_app_id

Per the Configure your environment guide, a defineSecret value lives in Secret Manager, reaches only functions that list it in secrets, and can be read with .value() only at run time. A defineString parameter comes from .env at deploy time. The guide also says you must redeploy every function that uses a secret after you set a new value, which matters when you rotate your keys and link salt.

Step 2: the postback function

This is the whole postback side, one file. It uses the Admin SDK, which the Firestore security rules guide confirms is not subject to security rules (“the server client libraries bypass all Cloud Firestore Security Rules”), so it can write wallets that clients cannot.

const crypto = require('node:crypto');
const { onRequest, onCall, HttpsError } = require('firebase-functions/https');
const { defineSecret, defineString } = require('firebase-functions/params');
const logger = require('firebase-functions/logger');
const { initializeApp } = require('firebase-admin/app');
const { getFirestore, FieldValue } = require('firebase-admin/firestore');

initializeApp();
const db = getFirestore();

const POSTBACK_KEY = defineSecret('SHARKLIO_POSTBACK_KEY');
const LINK_SALT = defineSecret('SHARKLIO_LINK_SALT');
const API_KEY = defineSecret('SHARKLIO_API_KEY');
const APP_ID = defineString('SHARKLIO_APP_ID');

const KEYS = ['tx', 'user', 'reward', 'status', 'hash'];
const USER_ID_OK = /^[A-Za-z0-9._@:+-]{1,100}$/;

function readParams(query) {
  const p = {};
  for (const key of KEYS) {
    if (typeof query[key] !== 'string') return null;
    p[key] = query[key];
  }
  return p;
}

function validHash(p, key) {
  const expected = Buffer.from(
    crypto.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 && crypto.timingSafeEqual(given, expected);
}

function toMilli(reward) {
  const m = /^(\d{1,12})(?:\.(\d{1,3}))?$/.exec(reward);
  if (!m) return null;
  return Number(m[1]) * 1000 + Number((m[2] || '').padEnd(3, '0'));
}

function txRef(tx) {
  const id = crypto.createHash('sha256').update(tx).digest('hex');
  return db.collection('sharklio_tx').doc(id);
}

async function applyEvent(p, milli) {
  const status = Number(p.status);
  const ref = txRef(p.tx);
  await db.runTransaction(async (t) => {
    const snap = await t.get(ref);
    const last = snap.exists ? snap.get('lastStatus') : null;

    if (status === 1 && last === null) {
      t.create(ref, {
        tx: p.tx, uid: p.user, rewardMilli: milli,
        lastStatus: 1, updatedAt: FieldValue.serverTimestamp(),
      });
      t.set(db.collection('wallets').doc(p.user),
        { coinsMilli: FieldValue.increment(milli) }, { merge: true });
    } else if (status === 2 && last === 1) {
      t.update(ref, { lastStatus: 2, updatedAt: FieldValue.serverTimestamp() });
      t.set(db.collection('wallets').doc(snap.get('uid')),
        { coinsMilli: FieldValue.increment(-snap.get('rewardMilli')) }, { merge: true });
    } else if (status === 2 && last === null) {
      t.create(ref, {
        tx: p.tx, uid: p.user, rewardMilli: milli,
        lastStatus: 2, updatedAt: FieldValue.serverTimestamp(),
      });
    }
  });
}

exports.sharklioPostback = onRequest(
  { secrets: [POSTBACK_KEY], invoker: 'public', timeoutSeconds: 10 },
  async (req, res) => {
    if (req.method !== 'GET') {
      res.status(405).end();
      return;
    }
    const p = readParams(req.query);
    if (!p || !validHash(p, POSTBACK_KEY.value())) {
      res.status(403).end();
      return;
    }
    if (p.tx.startsWith('test_')) {
      logger.info('sharklio test postback', { tx: p.tx, status: p.status });
      res.status(200).end();
      return;
    }
    const milli = toMilli(p.reward);
    if (milli === null || (p.status !== '1' && p.status !== '2')) {
      logger.error('sharklio postback with unexpected values', { tx: p.tx, reward: p.reward, status: p.status });
      res.status(500).end();
      return;
    }
    try {
      await applyEvent(p, milli);
      res.status(200).end();
    } catch (err) {
      if (err.code === 6) {
        res.status(200).end();
        return;
      }
      logger.error('sharklio postback failed', { tx: p.tx, err: String(err) });
      res.status(500).end();
    }
  }
);

Why it is written this way

  • Hash first, values unchanged. The HMAC covers the transaction ID, user ID, reward and status joined with colons, exactly as they arrived, signed with the postback secret key. Nothing is parsed or trimmed before the check. This is the format in the Verify the hash section of the docs, and What is HMAC? explains why a signature beats a shared password in the URL.
  • Length check before timingSafeEqual. On Node.js 22, crypto.timingSafeEqual throws a RangeError (ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH) when the buffers differ in length. Without the check, a short forged hash would make the function answer 500 instead of 403.
  • One string per parameter. A repeated parameter such as ?tx=a&tx=b arrives as an array, so readParams rejects anything that is not exactly one string.
  • Integers, not floats. A reward can have up to three decimals, for example 0.375. Firestore stores numbers as 64-bit integers or 64-bit IEEE 754 doubles (see supported data types), and adding decimals as doubles drifts. toMilli turns the reward string into whole thousandths without floating point, and the wallet holds coinsMilli. Show coinsMilli / 1000 in your app.
  • A hashed document ID. Firestore document IDs cannot contain a forward slash, cannot be . or .., and cannot match __.*__ (see usage and limits). A SHA-256 of the transaction ID is always valid and still unique, and the raw value stays in the tx field.
  • Test postbacks are logged, not credited. A test from the dashboard always has a transaction ID starting with test_. Remove that branch if you would rather credit a test account and take the coins back by hand.
  • 500 means “try again”. Unexpected errors and unreadable values return 500 and are logged, so Sharklio retries and the attempt shows in Reports, Postbacks.

Step 3: credit once with a Firestore transaction

Postbacks can arrive twice: Sharklio retries a failed or slow attempt up to 5 times, after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, and you can resend one from the dashboard. The transaction document is what makes a second copy harmless.

The Google Cloud Transaction reference for Node.js says get() holds a pessimistic lock on the document it returns, and that create() fails the transaction if a document already exists at that location. Firebase’s transactions guide adds that reads must come before writes and that a transaction never partially applies its writes. So the function reads first, decides, then writes both documents together.

Incoming statusTransaction documentWhat the function does
1 (credited)missingcreate() it with lastStatus: 1, add the reward to the wallet
1 (credited)existsNothing, answers 200
2 (reversed)lastStatus: 1Set it to 2, subtract the stored reward from the stored UID
2 (reversed)missingcreate() it with lastStatus: 2, so a late credit cannot pay
2 (reversed)lastStatus: 2Nothing, answers 200

If two copies of a credit run at the same moment and both see no document, only one create() can commit. In our emulator test, a create() on an existing document failed with gRPC code 6 (ALREADY_EXISTS) on the first attempt, without a retry, which is why the handler treats code 6 as a duplicate and answers 200. Firing 20 identical credits in parallel credited the wallet exactly once.

Here is how the transaction compares with the shortcuts people try first:

ApproachDuplicate credit possible?Reversal safe?
FieldValue.increment() on the wallet aloneYes, every retry adds againNo record of what was credited
Check for the transaction document, then write, outside a transactionYes, two parallel copies both pass the checkSame race
set() on the transaction document plus an increment in a batchYes, set() overwrites silentlyCan subtract twice
Transaction: get(), then create() plus incrementNoYes, uses the stored reward and UID

A reversal carries the same positive {reward} as its credit. If the user already spent the coins, the balance goes below zero; you can hold withdrawals until it recovers instead, if your terms say so. Publisher earnings and reversals covers the business side, and the Credits and reversals docs list why results get reversed.

Firebase mistakes that cost real coins

Letting the app call increment() on its own wallet after the offerwall closes. Reading the UID from request.data in a callable instead of request.auth.uid. Putting the link hash salt in Remote Config or anywhere else your app can read it, where users can extract it. Sending the response first and writing to Firestore afterwards: Firebase’s tips page says code that runs after the function has terminated cannot use the CPU and makes no progress. And crediting {payout} instead of the signed {reward}.

The UID that Firebase Auth gives the user is the {user_id} that comes back in every postback, so the link must be built for exactly that UID, on the server. A callable function is the natural place: the callable functions guide says authentication details are added to the request automatically, and you read the UID from request.auth.uid.

exports.sharklioWallLink = onCall({ secrets: [LINK_SALT] }, (request) => {
  if (!request.auth) {
    throw new HttpsError('unauthenticated', 'Sign in to open the offerwall.');
  }
  const uid = request.auth.uid;
  if (!USER_ID_OK.test(uid)) {
    throw new HttpsError('failed-precondition', 'This account ID cannot be used on the offerwall.');
  }
  const hash = crypto.createHmac('sha256', LINK_SALT.value()).update(uid).digest('hex');
  return { url: `https://wall.sharklio.com/${APP_ID.value()}?user_id=${encodeURIComponent(uid)}&hash=${hash}` };
});

exports.sharklioSession = onCall({ secrets: [API_KEY] }, async (request) => {
  if (!request.auth) {
    throw new HttpsError('unauthenticated', 'Sign in to open the offerwall.');
  }
  let r;
  let data = {};
  try {
    r = await fetch('https://api.sharklio.com/v1/session', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${API_KEY.value()}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ user_id: request.auth.uid }),
      signal: AbortSignal.timeout(10000),
    });
    data = await r.json().catch(() => ({}));
  } catch (e) {
    throw new HttpsError('unavailable', 'offerwall_unavailable');
  }
  if (r.status === 201 && data.token) {
    return { token: data.token };
  }
  logger.warn('sharklio session failed', { status: r.status, error: data.error });
  throw new HttpsError(r.status === 403 ? 'permission-denied' : 'unavailable', 'offerwall_unavailable');
});
  • sharklioWallLink returns the signed link for a WebView, an iframe or the system browser, built exactly as the offerwall link docs show: HMAC-SHA256 of the user ID with the link hash salt, lowercase hex.
  • sharklioSession is for the native SDKs. It calls POST https://api.sharklio.com/v1/session with your API key and returns only the token, which works once and for 10 minutes. Call it every time the user taps Earn.
  • The UID check. The Admin SDK docs on managing users allow custom UIDs of 1 to 128 characters, while the offerwall accepts 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -. If you mint your own UIDs, keep them inside that range. The function refuses anything else instead of building a link that fails.
  • App Check. If your app already uses Firebase App Check, add enforceAppCheck: true to the two callable options. Never add it to the postback function: Sharklio’s servers cannot send an App Check token.

On the client, the call is a few lines. In Flutter with the cloud_functions package and the Sharklio Flutter plugin:

final result = await FirebaseFunctions.instance.httpsCallable('sharklioSession').call();
final token = result.data['token'] as String;
await Sharklio.open(token);

On the web, with the modular SDK:

import { getFunctions, httpsCallable } from 'firebase/functions';

const getLink = httpsCallable(getFunctions(), 'sharklioWallLink');
const { data } = await getLink();
document.querySelector('#wall').src = data.url;

A native Android app does the same with getHttpsCallable("sharklioSession") and then Sharklio.open(activity, token), as in How to add an offerwall to an Android app in Kotlin. An iframe only loads on https pages of the website you registered for the app; a WebView or the system browser works anywhere.

Step 5: security rules for the wallet

Keep balances in their own collection so the rule is simple: a signed-in user can read their own wallet and nobody can write it from a client.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /wallets/{uid} {
      allow read: if request.auth != null && request.auth.uid == uid;
      allow write: if false;
    }
    match /sharklio_tx/{id} {
      allow read, write: if false;
    }
  }
}

Your app listens to wallets/{uid} with a snapshot listener and shows the new balance as soon as the postback commits. For a push notification per reward, use a separate onDocumentCreated function on sharklio_tx, so the postback stays fast and a failed notification never blocks a credit.

Cold starts, timeouts and cost

Sharklio waits up to 6 seconds for a 2xx answer, does not read the body and does not follow redirects. Three Firebase details matter here:

  • Cold starts. Firebase’s tips page says a new instance starts from scratch and that cold starts “can take significant amounts of time”. If one runs past 6 seconds, Sharklio retries a minute later, and the transaction document makes the retry a harmless repeat.
  • Minimum instances. minInstances: 1 keeps one instance warm. The manage functions page warns that warm instances are billed at idle rates even with no traffic, so most apps should start without it and add it only if Reports, Postbacks shows timeouts.
  • The URL. A deployed 2nd gen HTTP function answers at https://us-central1-<project-id>.cloudfunctions.net/sharklioPostback (the region is part of the address). The invoker: 'public' option lets Sharklio call it without a Google identity token. Paste the exact URL the CLI prints, so there is no redirect.

Pricing (checked on October 22, 2026): the Firebase pricing page gives Blaze projects 2 million function invocations, 400,000 GB-seconds and 200,000 CPU-seconds a month at no cost, then $0.40 per million invocations. Cloud Firestore Standard edition includes 50,000 document reads and 20,000 writes a day at no cost. The config guide says Secret Manager allows 6 active secret versions and 10,000 monthly access operations at no cost, then $0.03 per 10,000 access operations. Each credit in this design costs one read and two writes. Illustrative example only: 1,000 credits a day would be 1,000 reads and 2,000 writes, well inside the daily no-cost Firestore quota, but your other app traffic shares that quota.

Step 6: test with the Firebase Emulator Suite

You can run the whole flow locally with a demo- project ID, which needs no real Firebase project or credentials:

  1. Put fake secrets in functions/.secret.local (the config guide says the emulator uses it to override secret values), for example SHARKLIO_POSTBACK_KEY=local_test_key, and the App ID in functions/.env.
  2. Start Functions, Firestore and Auth: npx firebase emulators:start --only functions,firestore,auth --project demo-myapp. The emulator page still lists Java 11, but firebase-tools 15.34.1 refused to start the Firestore emulator on Java 17 for us and asked for Java 21 or newer.
  3. Send a signed credit (sample values only):
    TX=local_1 U=testUid42 R=150 S=1
    HASH=$(node -e "const c=require('node:crypto');console.log(c.createHmac('sha256','local_test_key').update(process.argv[1]).digest('hex'))" "$TX:$U:$R:$S")
    curl -i "http://127.0.0.1:5001/demo-myapp/us-central1/sharklioPostback?tx=$TX&user=$U&reward=$R&status=$S&hash=$HASH"
    Run it twice: wallets/testUid42 should hold coinsMilli: 150000 once. Change S to 2, recompute the hash and run it again: the balance goes back to 0.
  4. Change one character of the hash and you must get 403. Add &tx=x to the URL: also 403.

What our emulator run checked

Credits, repeats, 20 parallel copies, reversals, a reversal before its credit, a 0.375 reward, forged and short hashes, a repeated parameter, a test_ postback, a slash in the transaction ID, POST (405), the callable without sign-in (401), the signed link for an Auth emulator user, and the rules. All 29 checks passed, and a separate run confirmed the ALREADY_EXISTS code.

After you deploy, save the postback URL in your app’s Postback tab:

https://us-central1-YOUR_PROJECT.cloudfunctions.net/sharklioPostback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}

Then use Send a test postback in Integration status for status 1 and status 2, and check that Reports, Postbacks shows 200. Check a link and its Test a link tool show the exact link your callable should return for a UID. The full checklist is in How to test your app before it goes live, and the Testing docs list common errors. If a postback fails for good, resend it from the dashboard after you fix the cause.

One postback function 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, Flutter, React Native or Unity SDKs, so the function above serves all of them. Each app sets its own currency name, conversion rate, user split and rounding, and {reward} arrives already converted. Every publisher application is reviewed by hand, and apps go live right away once your account is approved. See how the wall works, or get in touch.

Frequently asked questions

Can a Firebase Cloud Function receive a webhook or postback?

Yes. A 2nd gen onRequest function with public invoker access is an HTTPS endpoint with an Express-style request and response, so it can receive, verify and answer a GET postback.

Do I need the Blaze plan to receive offerwall postbacks on Firebase?

Yes. Cloud Functions for Firebase requires the pay-as-you-go Blaze plan, which still includes a monthly no-cost allowance of invocations and compute.

How do I increment a balance only once in a Firestore transaction?

Read a document keyed by the transaction ID inside runTransaction, and if it is missing, create() it and apply FieldValue.increment() to the wallet in the same transaction. A second copy finds the document, or fails with ALREADY_EXISTS, and credits nothing.

Where do I store the postback secret key in Firebase?

In Cloud Secret Manager, through defineSecret and firebase functions:secrets:set. Bind it to the postback function with the secrets option and read it with .value() inside the handler.

Should the offerwall user ID be the Firebase Auth UID?

Yes, as long as it is 1 to 100 characters from the allowed set. It never changes for a user, the server can read it from the verified Auth token, and it comes back as {user_id} in every postback.

Why does my Firebase postback time out?

Usually a cold start plus slow work before the response. Keep the handler to the hash check and one transaction, move other work to a Firestore trigger, and try minInstances only if timeouts continue.