Offerwall Postback in Python: Django and Flask

Key takeaways

  • Hash the four query values exactly as they arrive, in the order transaction ID, user ID, reward, status, and compare with hmac.compare_digest on bytes so odd input gets a clean 403.
  • Let a unique transaction ID and one database transaction decide whether to credit: ON CONFLICT DO NOTHING RETURNING in Flask with PostgreSQL, bulk_create with select_for_update in Django.
  • Use the exact final URL, including Django's trailing slash, on port 80, 443, 8080 or 8443, and answer 200 within 6 seconds for every event you handled or chose to ignore.

How do you handle an offerwall postback in Python?

A Python offerwall postback handler is a GET view that recomputes the HMAC-SHA256 of the transaction ID, user ID, reward, and status with your postback secret key, compares it to the hash parameter with hmac.compare_digest, and then credits (status 1) or takes back (status 2) the reward exactly once per transaction ID inside a database transaction. It answers 403 for a bad signature and 200 for everything it handled or ignored. In Flask that is one route; in Django it is one view, one model, and one URL pattern.

This guide gives you working code for both frameworks, plus the signed link that opens the wall. The reasoning behind each security check is in Postback security: verify every reward call, so here we focus on what is specific to Python, Flask, and Django. If you run PHP, the same flow is in Offerwall PHP integration: Laravel and plain PHP, and the Postbacks docs are the reference.

Who does what

  • Your Python server builds the signed offerwall link for the logged-in user, receives postbacks, and is the only place that changes balances.
  • The browser or app only opens the finished link. It never sees the link hash salt or the postback secret key, and it never reports that a reward was earned.
  • Sharklio sends one signed GET request for every event: status 1 when a result is credited, and status 2 if that same result is reversed later. Both carry the same transaction_id, and amounts are never negative.

The two frameworks reach the same result in slightly different ways. These are the differences that matter for this integration:

TopicFlaskDjango
Route@app.get("/sharklio/postback")path("sharklio/postback/", ...) with @require_GET
Reading valuesrequest.args.get(), first value of a repeated keyrequest.GET.get(), last value of a repeated key
CSRFCore Flask has no CSRF checkGET is not checked; @csrf_exempt on this one view only
Trailing slash trapA rule ending in / redirects requests without itAPPEND_SLASH redirects a URL without the slash
TransactionA psycopg.connect() block commits or rolls back@transaction.atomic
Credit onceINSERT ... ON CONFLICT DO NOTHING RETURNINGbulk_create(ignore_conflicts=True), then select_for_update()
Reverse onceUPDATE ... WHERE last_status = 1 RETURNINGselect_for_update(), then check last_status

From the Integration tab of your app you need three values: the app ID in your offerwall link, the link hash salt, and the postback secret key. Keep them in environment variables. The salt signs the link, and the key signs postbacks; mixing them up is the most common cause of a Link not valid page.

The link hash is the HMAC-SHA256 of the user ID with the link hash salt, as lowercase hex, which is what hexdigest() returns. This helper uses the same calls as the Offerwall link docs:

# sharklio.py
import hashlib
import hmac
import os
from urllib.parse import quote

APP_ID = os.environ["SHARKLIO_APP_ID"]
LINK_SALT = os.environ["SHARKLIO_LINK_SALT"]


def wall_url(user_id):
    user_id = str(user_id)
    sig = hmac.new(LINK_SALT.encode(), user_id.encode(), hashlib.sha256).hexdigest()
    return f"https://wall.sharklio.com/{APP_ID}?user_id={quote(user_id)}&hash={sig}"

Convert the ID to a string once and use that string for both the hash and the link. The user ID must never change for the same person and may use 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -, so a primary key or a UUID works well. quote() URL-encodes characters such as @ and +. In Django, read the app ID and the salt from settings instead of os.environ.

The earn page in Flask

from flask import redirect, render_template, session, url_for


@app.get("/earn")
def earn():
    user_id = session.get("user_id")
    if user_id is None:
        return redirect(url_for("login"))
    html = render_template("earn.html", wall_url=wall_url(user_id))
    return html, 200, {"Cache-Control": "private, no-store"}

The earn page in Django

from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.views.decorators.cache import never_cache

from .sharklio import wall_url


@login_required
@never_cache
def earn(request):
    return render(request, "earn.html", {"wall_url": wall_url(request.user.pk)})

Django’s never_cache adds Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private, which stops a cache from serving one user’s signed link to someone else. Both frameworks can share the same template:

<h1>Earn coins</h1>
<iframe title="Earn coins" src="{{ wall_url }}"
  style="border:0;width:100%;height:100vh"></iframe>

Jinja and Django templates both escape the & in the link to &amp;, which is correct inside an HTML attribute, so do not mark the URL as safe. The iframe only loads on https pages of the website you registered for the app. For a button in the corner of every page instead, see Add a floating Earn button to any website, and for the words around the wall, How to write an earn page that converts.

Step 2: one row per transaction ID

Every event of one result carries the same transaction ID, and the primary key on that column is what makes a duplicate harmless. Store rewards as Decimal, never float: a reward such as 0.296 must stay exactly that.

For Flask with PostgreSQL:

CREATE TABLE sharklio_tx (
    transaction_id varchar(100) PRIMARY KEY,
    user_id        varchar(100) NOT NULL,
    reward         numeric(16, 3) NOT NULL,
    last_status    smallint NOT NULL,
    created_at     timestamptz NOT NULL DEFAULT now()
);

For Django, a model in your rewards app. The examples also assume a coins field on your user model, such as models.DecimalField(max_digits=16, decimal_places=3, default=0); adapt the two balance lines if your wallet lives elsewhere.

# rewards/models.py
from django.db import models


class SharklioTransaction(models.Model):
    transaction_id = models.CharField(max_length=100, primary_key=True)
    wall_user_id = models.CharField(max_length=100)
    reward = models.DecimalField(max_digits=16, decimal_places=3)
    last_status = models.PositiveSmallIntegerField(null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

The field is called wall_user_id rather than user_id so nobody mistakes it for a foreign key column.

Step 3: the Flask postback view

Set this postback URL in your app’s Postback tab. The parameter names are yours to choose; the macros are Sharklio’s:

https://yoursite.com/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
# app.py
import hashlib
import hmac
import logging
import os
from decimal import Decimal

import psycopg
from flask import Flask, abort, request

app = Flask(__name__)
log = logging.getLogger("sharklio")
POSTBACK_KEY = os.environ["SHARKLIO_POSTBACK_KEY"].encode()
DATABASE_URL = os.environ["DATABASE_URL"]


def valid_signature(tx, user, reward, status, given):
    msg = f"{tx}:{user}:{reward}:{status}".encode()
    expected = hmac.new(POSTBACK_KEY, msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), given.encode())


@app.get("/sharklio/postback")
def sharklio_postback():
    q = request.args
    tx, user, reward, status = (q.get(k, "") for k in ("tx", "user", "reward", "status"))
    if not valid_signature(tx, user, reward, status, q.get("hash", "")):
        abort(403)
    if tx.startswith("test_"):
        log.info("Sharklio test postback %s", q.to_dict())
        return "OK"

    with psycopg.connect(DATABASE_URL) as conn:
        if status == "1":
            amount = Decimal(reward)
            inserted = conn.execute(
                "INSERT INTO sharklio_tx (transaction_id, user_id, reward, last_status)"
                " VALUES (%s, %s, %s, 1) ON CONFLICT (transaction_id) DO NOTHING"
                " RETURNING transaction_id",
                (tx, user, amount),
            ).fetchone()
            if inserted:
                cur = conn.execute(
                    "UPDATE users SET coins = coins + %s WHERE id = %s::bigint",
                    (amount, user),
                )
                if cur.rowcount == 0:
                    log.warning("Sharklio credit for unknown user %s (tx %s)", user, tx)
        elif status == "2":
            row = conn.execute(
                "UPDATE sharklio_tx SET last_status = 2"
                " WHERE transaction_id = %s AND last_status = 1"
                " RETURNING user_id, reward",
                (tx,),
            ).fetchone()
            if row:
                conn.execute(
                    "UPDATE users SET coins = coins - %s WHERE id = %s::bigint",
                    (row[1], row[0]),
                )
    return "OK"

Why it is written this way:

  1. The key is read at import. If SHARKLIO_POSTBACK_KEY is missing, the app refuses to start instead of accepting signatures made with an empty key.
  2. Values are hashed exactly as they arrive. Decimal(reward) is only created after the check, because a reformatted 0.2960 would no longer match.
  3. The comparison runs on bytes. Python’s hmac documentation says compare_digest avoids content-based short-circuiting, and that two str arguments must be ASCII only. A forged hash such as %C3%A9 would raise a TypeError and a 500, which Sharklio does not send but a scanner might, so encoding both sides turns it into a clean 403.
  4. The insert is the credit gate. The PostgreSQL INSERT docs say RETURNING returns only rows actually inserted, so a duplicate returns nothing and pays nothing. A second copy that arrives at the same moment waits on the primary key until the first commits.
  5. The reversal is a conditional update. Under PostgreSQL’s default Read Committed level, a concurrent update waits for the first one and re-checks the WHERE clause, so last_status = 1 can only flip to 2 once. It takes back the amount stored with the credit, from the user it was credited to.
  6. The connection block is the transaction. The psycopg 3 docs say a with psycopg.connect() block commits at the end and rolls back on an exception. Any error becomes a 500, nothing is half-applied, and Sharklio retries.

The %s::bigint cast assumes numeric user IDs; use ::uuid for UUIDs. In production, reuse connections from a pool rather than opening one per request.

Step 4: the Django postback view

# settings.py
SHARKLIO_APP_ID = os.environ["SHARKLIO_APP_ID"]
SHARKLIO_LINK_SALT = os.environ["SHARKLIO_LINK_SALT"]
SHARKLIO_POSTBACK_KEY = os.environ["SHARKLIO_POSTBACK_KEY"]

# urls.py
urlpatterns = [
    path("earn/", views.earn, name="earn"),
    path("sharklio/postback/", views.sharklio_postback),
]
# rewards/views.py
import hashlib
import hmac
import logging
from decimal import Decimal

from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import F
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET

from .models import SharklioTransaction

log = logging.getLogger("sharklio")
User = get_user_model()


def valid_signature(tx, user, reward, status, given):
    msg = f"{tx}:{user}:{reward}:{status}".encode()
    key = settings.SHARKLIO_POSTBACK_KEY.encode()
    expected = hmac.new(key, msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), given.encode())


@csrf_exempt
@require_GET
def sharklio_postback(request):
    q = request.GET
    tx, user, reward, status = (q.get(k, "") for k in ("tx", "user", "reward", "status"))
    if not valid_signature(tx, user, reward, status, q.get("hash", "")):
        return HttpResponseForbidden()
    if tx.startswith("test_"):
        log.info("Sharklio test postback %s", q.dict())
        return HttpResponse("OK")
    if status == "1":
        credit(tx, user, Decimal(reward))
    elif status == "2":
        reverse(tx)
    return HttpResponse("OK")


@transaction.atomic
def credit(tx, user, amount):
    SharklioTransaction.objects.bulk_create(
        [SharklioTransaction(transaction_id=tx, wall_user_id=user, reward=amount)],
        ignore_conflicts=True,
    )
    row = SharklioTransaction.objects.select_for_update().get(pk=tx)
    if row.last_status is not None:
        return
    if not User.objects.filter(pk=user).update(coins=F("coins") + amount):
        log.warning("Sharklio credit for unknown user %s (tx %s)", user, tx)
    row.last_status = 1
    row.save(update_fields=["last_status", "updated_at"])


@transaction.atomic
def reverse(tx):
    row = SharklioTransaction.objects.select_for_update().filter(pk=tx).first()
    if row is None or row.last_status != 1:
        return
    User.objects.filter(pk=row.wall_user_id).update(coins=F("coins") - row.reward)
    row.last_status = 2
    row.save(update_fields=["last_status", "updated_at"])

Because the pattern ends in a slash, your postback URL must too: https://yoursite.com/sharklio/postback/?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}. Without it, APPEND_SLASH answers with a 301, and Sharklio does not follow redirects. The same goes for SECURE_SSL_REDIRECT: enter the https address.

Why csrf_exempt, and only here

The Django CSRF docs say the token check applies to every request that is not GET, HEAD, OPTIONS, or TRACE. Sharklio sends GET, so strictly the view works without @csrf_exempt. We still put it on this one view because the caller is a server with no CSRF cookie or token, so if the route ever accepted POST, every postback would get a 403 that looks exactly like a hash failure in your report. The HMAC signature is what authenticates this request, not a cookie.

Keep the scope that narrow. Never remove CsrfViewMiddleware or exempt other views to make postbacks work, and do not put this view behind @login_required. @require_GET answers 405 to any other method, so the exemption never applies to a form post.

What the transaction does

  • bulk_create(ignore_conflicts=True) inserts the row or silently skips a duplicate key. It is used instead of create() because the Django transactions docs warn that after an IntegrityError inside atomic, the transaction is broken until it rolls back.
  • select_for_update() locks the row until the end of the transaction, so two copies of one event wait for each other. A row whose last_status is still empty can only be the one this request just inserted.
  • F("coins") + amount changes the balance in the database, so no other request can overwrite it with a stale value.
  • Any exception rolls everything back and returns a 500, which Sharklio retries.

The QuerySet reference adds two caveats. On SQLite, select_for_update() has no effect, so use PostgreSQL or MySQL in production. And inside a normal TestCase it does not raise even outside atomic, so test the locking with TransactionTestCase.

Handling reversals in Python

A status 2 postback carries the same transaction ID as the credit and the same positive reward. Both views take back the stored amount once, and only if they credited it. A reversal for a transaction you never credited, including one you skipped as a test, is ignored with a 200. Two details deserve a decision before launch:

  • Negative balances. If the user already spent the coins, the subtraction pushes the balance below zero. That is usually the right record, but it fails if your column cannot hold it: a PositiveIntegerField or a CHECK (coins >= 0) constraint turns every such reversal into a 500, five retries, and a credit that stays in place. Allow negative values and hold withdrawals until the balance recovers, as the Credits and reversals docs suggest, and say so in your terms.
  • Side effects. If a credit sends a push notification or email, register it with transaction.on_commit() in Django, which the docs say runs only after a successful commit and is discarded on rollback. Hand the work to a background queue there, because the callback still runs inside the request, and Sharklio waits only 6 seconds.

How reversals affect your own earnings is covered in Publisher earnings, settlement, and reversals.

Status codes, retries, and logging

SituationAnswerWhat happens next
Credit or reversal applied200Done
Duplicate, unknown reversal, or test_ transaction200Done, nothing changed
Hash does not match403Counted as failed and retried
Database down or any exception500Retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours
Redirect from a slash or http301 or 302Fails; Sharklio does not follow redirects
No answer within 6 secondsTimeoutRetried, so the work may already be done: idempotency covers it

If the last retry fails, Sharklio emails you, at most once a day per app, and you can send it again from Reports, Postbacks, as explained in How to resend a failed postback. Add event={event_id} and the sub IDs to your URL if you want them in your logs, but credit only the signed reward: other macros such as {payout} are not part of the hash. If you ever replace the postback secret key, the old one stops working at once, so follow How to rotate your keys and link salt and deploy the new environment variable right away.

Step 5: test before you go live

Sharklio only calls public servers on ports 80, 443, 8080, or 8443. Flask’s development server on port 5000 and Django’s runserver on port 8000 cannot receive real postbacks, so test locally with your own signed URL first:

import hashlib
import hmac
import os
from urllib.parse import urlencode

key = os.environ["SHARKLIO_POSTBACK_KEY"].encode()
tx, user, reward, status = "local_1", "42", "315", "1"
sig = hmac.new(key, f"{tx}:{user}:{reward}:{status}".encode(), hashlib.sha256).hexdigest()
params = {"tx": tx, "user": user, "reward": reward, "status": status, "hash": sig}
print("http://127.0.0.1:8000/sharklio/postback/?" + urlencode(params))
  1. Open the printed URL twice: user 42 gets 315 coins once. Change status to "2" and open it twice more: the coins come back off once. Edit one character of the hash: 403.
  2. Deploy, then in Integration status on the Integration tab use Check a link and compare its Test a link output with what wall_url() builds for the same user ID. They must be identical.
  3. Use Send a test postback. Its transaction ID starts with test_, so your view verifies the hash and logs it without crediting anyone. A 200 in the result window means the signature, URL, and routing are right.
  4. Check every attempt and status code under Reports, Postbacks.

The full checklist is in How to test your app before it goes live. A Mini App backend written in Python uses the same view; Add an offerwall to a Telegram Mini App covers the Telegram side, and Offerwall postback in Node.js has the Express version.

Python mistakes that break postbacks

Comparing hashes with ==. Converting the reward to float or Decimal before hashing. Signing the link with the postback secret key. A Django URL without its trailing slash. Catching IntegrityError inside atomic and carrying on. Testing locking on SQLite and assuming it works in production.

Where this fits on Sharklio

Sharklio is a pay-per-result affiliate network, and on the web the wall runs as a link or iframe that your server builds and signs, exactly like wall_url() above. Every credit and every reversal reaches your server as a signed server-to-server postback, and your dashboard and Reporting API show clicks, conversions, and reversals by country, offer, and sub ID. If you would rather build your own wall, there is an Offers API, and for apps and games there are Android and iOS SDKs, a Unity plugin, a Flutter plugin, and a React Native package. Every publisher application and every app is reviewed by hand before the wall goes live. See how the wall works, or read how to get approved as a publisher.

Frequently asked questions

How do I verify a postback hash in Python?

Join the transaction ID, user ID, reward, and status with colons, compute hmac.new(key, message, hashlib.sha256).hexdigest() with your postback secret key, and compare it to the received hash with hmac.compare_digest. Use the values exactly as they arrived in the query string.

Do I need csrf_exempt on a Django postback view?

Not for GET, because Django does not check CSRF tokens on GET requests. Adding @csrf_exempt to that one view keeps it working if it ever accepts POST, and the HMAC check protects it instead.

Why does my Django postback URL return a 301?

Your URL pattern ends in a slash and the postback URL does not, so APPEND_SLASH redirects it. Add the trailing slash to the URL in your Postback tab, and use https if SECURE_SSL_REDIRECT is on.

Why does hmac.compare_digest raise a TypeError?

When both arguments are strings, Python only accepts ASCII. Encode both sides to bytes, as in the code above, and a hash with unusual characters simply fails the check.

Can I receive postbacks on localhost?

No. Postbacks go only to a public server on port 80, 443, 8080, or 8443. Test locally with a URL you sign yourself, then use Send a test postback once the view is deployed.

Does the same approach work with FastAPI?

Yes. The signature, the unique transaction ID, and the single database transaction are the same in any Python framework; only the routing and the way you read query parameters change.