Offerwall Postback in C# and ASP.NET Core

Key takeaways

  • Read each query parameter as exactly one string, hash the four signed values unchanged, and compare lowercase hex bytes with CryptographicOperations.FixedTimeEquals before you touch the database.
  • Let the transaction ID primary key and one EF Core transaction decide: insert with ON CONFLICT DO NOTHING to credit once, flip status 1 to 2 to reverse once, and record reversals you never credited.
  • Return Results.StatusCode(403), not Results.Forbid(), keep the route anonymous and free of redirects, and answer 200 within 6 seconds for every event you handled or chose to ignore.

How do you handle an offerwall postback in C#?

In ASP.NET Core, an offerwall postback handler is a GET endpoint that reads the transaction ID, user ID, reward, status and hash as single strings, recomputes the HMAC-SHA256 of tx:user:reward:status with your postback secret key, and compares the lowercase hex with CryptographicOperations.FixedTimeEquals. If it matches, one EF Core transaction adds the reward on status 1 and takes it back on status 2, once per transaction ID, guarded by the primary key. The endpoint answers 403 for a bad hash, 200 for everything it applied or ignored, and 500 when your own code fails, so the event is retried.

Below are three small files for .NET 8 or newer, plus the signed offerwall link and a session endpoint for the Unity plugin. The reasoning behind each check is in Postback security: verify every reward call, and What is a postback URL? covers the basics. The same flow in other languages is in Node.js, Python and PHP and Laravel.

Who does what

PartDoesNever does
Browser, app or Unity gameAsks your server for a signed link or a session token and opens itHolds the link hash salt, the postback secret key or the API key, adds currency
Your ASP.NET Core serverSigns links, verifies postbacks, owns the wallet tableTrusts a balance change that did not come from a verified postback
SharklioShows offers, tracks results, sends a signed postback for every credit and every reversalPays your users directly

From your app’s Integration tab you need the app ID in your offerwall link, the link hash salt, the postback secret key, and, for the mobile SDKs, the API key. The salt signs links and the key signs postbacks; mixing them up is the most common cause of a Link not valid page or a 403 on every postback.

Step 1: options and the signing helper

Keep the secrets out of source control. With the default ASP.NET Core configuration, the environment variables Sharklio__AppId, Sharklio__LinkSalt, Sharklio__PostbackKey and Sharklio__ApiKey fill the class below; in development, user secrets work the same way.

// Sharklio/SharklioSigner.cs
using System.Security.Cryptography;
using System.Text;

public sealed class SharklioOptions
{
    public string AppId { get; set; } = "";
    public string LinkSalt { get; set; } = "";
    public string PostbackKey { get; set; } = "";
    public string ApiKey { get; set; } = "";
}

public static class SharklioSigner
{
    public static string WallUrl(SharklioOptions o, string userId)
    {
        string hash = HmacHex(o.LinkSalt, userId);
        return $"https://wall.sharklio.com/{o.AppId}?user_id={Uri.EscapeDataString(userId)}&hash={hash}";
    }

    public static bool IsValidPostback(SharklioOptions o, string tx, string user, string reward, string status, string hash)
    {
        byte[] expected = Encoding.ASCII.GetBytes(HmacHex(o.PostbackKey, $"{tx}:{user}:{reward}:{status}"));
        byte[] given = Encoding.UTF8.GetBytes(hash);
        return CryptographicOperations.FixedTimeEquals(expected, given);
    }

    static string HmacHex(string key, string message)
    {
        byte[] mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(key), Encoding.UTF8.GetBytes(message));
        return Convert.ToHexString(mac).ToLowerInvariant();
    }
}

Why it is written this way

  • Lowercase hex. Both hashes are lowercase hex, but Microsoft’s Convert.ToHexString reference says it uses uppercase, so the helper lowercases the result (on .NET 9 and newer, Convert.ToHexStringLower does it in one call). Skip this and every link and postback fails.
  • Static HashData. HMACSHA256.HashData exists since .NET 6, so there is no instance to share or dispose.
  • Bytes, not strings. The FixedTimeEquals docs say it compares two byte spans without leaking timing information, returns false when the lengths differ, and short-circuits only in that case. It does not throw on a length mismatch, so a short or non-ASCII forged hash is a plain 403. Never use == or string.Equals.
  • Values hashed unchanged. The four values go into the HMAC exactly as ASP.NET Core decoded them from the query string. Parse nothing before the check.
  • One user ID string. Use the same string for the hash and the link. It must never change for the same person and may use 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -; Uri.EscapeDataString encodes them for the URL.

Step 2: one row per transaction ID

Every event of one result carries the same transaction ID, so the primary key on that column (a unique index) makes a duplicate harmless. The examples use PostgreSQL with the Npgsql EF Core provider, and decimal amounts, never double.

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
);

The matching EF Core model. User stands for your existing user entity with a long key and a decimal balance; keep your own mapping and add the transaction table next to it, or create it with a migration.

// Data/AppDb.cs
using Microsoft.EntityFrameworkCore;

public class User
{
    public long Id { get; set; }
    public decimal Coins { get; set; }
}

public class SharklioTx
{
    public string TransactionId { get; set; } = "";
    public string UserId { get; set; } = "";
    public decimal Reward { get; set; }
    public short LastStatus { get; set; }
}

public class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
    public DbSet<User> Users => Set<User>();
    public DbSet<SharklioTx> SharklioTx => Set<SharklioTx>();

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<User>(e =>
        {
            e.ToTable("users");
            e.Property(x => x.Id).HasColumnName("id");
            e.Property(x => x.Coins).HasColumnName("coins").HasPrecision(16, 3);
        });
        b.Entity<SharklioTx>(e =>
        {
            e.ToTable("sharklio_tx");
            e.HasKey(x => x.TransactionId);
            e.Property(x => x.TransactionId).HasColumnName("transaction_id").HasMaxLength(100);
            e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(100);
            e.Property(x => x.Reward).HasColumnName("reward").HasPrecision(16, 3);
            e.Property(x => x.LastStatus).HasColumnName("last_status");
        });
    }
}

The explicit names matter because the crediting code uses two raw SQL inserts that must name the same columns.

Step 3: credit and reverse once, in one transaction

// Sharklio/SharklioPostbacks.cs
using System.Globalization;
using Microsoft.EntityFrameworkCore;

public static class SharklioPostbacks
{
    public static async Task ApplyAsync(AppDb db, string tx, string user, string reward, string status, ILogger log)
    {
        decimal amount = decimal.Parse(reward, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
        var strategy = db.Database.CreateExecutionStrategy();

        await strategy.ExecuteAsync(async () =>
        {
            await using var t = await db.Database.BeginTransactionAsync();

            if (status == "1")
            {
                int inserted = await db.Database.ExecuteSqlAsync($"""
                    INSERT INTO sharklio_tx (transaction_id, user_id, reward, last_status)
                    VALUES ({tx}, {user}, {amount}, 1)
                    ON CONFLICT (transaction_id) DO NOTHING
                    """);
                if (inserted == 1 && await AddCoinsAsync(db, user, amount) == 0)
                {
                    log.LogWarning("Sharklio credit for unknown user {User} (tx {Tx})", user, tx);
                }
            }
            else if (status == "2")
            {
                int reversed = await db.SharklioTx
                    .Where(x => x.TransactionId == tx && x.LastStatus == 1)
                    .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastStatus, (short)2));
                if (reversed == 1)
                {
                    var row = await db.SharklioTx.AsNoTracking().SingleAsync(x => x.TransactionId == tx);
                    await AddCoinsAsync(db, row.UserId, -row.Reward);
                }
                else
                {
                    await db.Database.ExecuteSqlAsync($"""
                        INSERT INTO sharklio_tx (transaction_id, user_id, reward, last_status)
                        VALUES ({tx}, {user}, {amount}, 2)
                        ON CONFLICT (transaction_id) DO NOTHING
                        """);
                }
            }

            await t.CommitAsync();
        });
    }

    static async Task<int> AddCoinsAsync(AppDb db, string user, decimal delta)
    {
        if (!long.TryParse(user, NumberStyles.None, CultureInfo.InvariantCulture, out long id)) return 0;
        return await db.Users
            .Where(u => u.Id == id)
            .ExecuteUpdateAsync(s => s.SetProperty(u => u.Coins, u => u.Coins + delta));
    }
}

What each part does

  • The insert is the credit gate. On status 1, the row is inserted only if the transaction ID is new. A retry or a second copy inserts nothing, gets 0 rows back and pays nothing. A copy arriving at the same moment waits on the primary key until the first commits, then also gets 0.
  • The reversal is a conditional update. Status 2 flips last_status from 1 to 2, which can happen only once, then takes back the stored amount from the user it was credited to.
  • A reversal you never credited is recorded. The handler stores it with status 2, so a late copy of that credit, for example one you resend by hand, cannot pay afterwards.
  • Parameterized SQL. Microsoft’s SQL queries page says ExecuteSql protects against SQL injection by turning each interpolated value into a database parameter, and returns the number of rows modified. Never build this SQL with string concatenation.
  • One explicit transaction. The ExecuteUpdate and ExecuteDelete docs say these methods take effect immediately and do not start a transaction of their own, so two of them without BeginTransactionAsync could half-apply. Disposing the transaction without CommitAsync rolls it back, so any exception leaves nothing changed and becomes a 500.
  • The execution strategy. EF Core’s connection resiliency page says a retrying strategy, such as EnableRetryOnFailure, throws on a user-initiated transaction unless you run it through CreateExecutionStrategy(). Wrapping it costs nothing without retries, and replaying the block is safe because the database decides what happens.
  • Invariant culture. The reward is parsed only after the hash check, with CultureInfo.InvariantCulture. Without it, decimal.Parse follows the server’s current culture, and a culture that uses a comma as the decimal separator would misread 0.315.
  • No change tracker. ExecuteUpdateAsync adds to the balance in the database, so no request can overwrite it with a stale value. Do not mix it with tracked SaveChanges edits of the same user.

The two ON CONFLICT inserts are the only PostgreSQL-specific lines. On SQL Server you need an equivalent insert-if-absent statement there; the rest of the class is the same.

If the user has already spent the coins, a reversal pushes the balance below zero. A CHECK (coins >= 0) constraint would turn 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. Publisher earnings and reversals covers the business side.

Step 4: the minimal API endpoints

// Program.cs
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<SharklioOptions>()
    .Bind(builder.Configuration.GetSection("Sharklio"))
    .Validate(o => o.AppId != "" && o.LinkSalt != "" && o.PostbackKey != "", "Sharklio keys are missing")
    .ValidateOnStart();
builder.Services.AddDbContext<AppDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// your existing AddAuthentication(...) and AddAuthorization() calls

var app = builder.Build();

app.MapGet("/api/offerwall-link", (ClaimsPrincipal me, IOptions<SharklioOptions> options, HttpResponse response) =>
{
    string userId = me.FindFirstValue(ClaimTypes.NameIdentifier)!;
    response.Headers.CacheControl = "private, no-store";
    return Results.Ok(new { url = SharklioSigner.WallUrl(options.Value, userId) });
}).RequireAuthorization();

app.MapGet("/sharklio/postback", async (HttpRequest request, IOptions<SharklioOptions> options, AppDb db, ILogger<Program> log) =>
{
    var q = request.Query;
    string? tx = One(q, "tx"), user = One(q, "user"), reward = One(q, "reward"),
            status = One(q, "status"), hash = One(q, "hash");

    if (tx is null || user is null || reward is null || status is null || hash is null
        || !SharklioSigner.IsValidPostback(options.Value, tx, user, reward, status, hash))
    {
        return Results.StatusCode(403);
    }
    if (tx.StartsWith("test_", StringComparison.Ordinal))
    {
        log.LogInformation("Sharklio test postback {Tx} status {Status}", tx, status);
        return Results.Ok();
    }

    await SharklioPostbacks.ApplyAsync(db, tx, user, reward, status, log);
    return Results.Ok();
}).AllowAnonymous();

app.Run();

static string? One(IQueryCollection q, string key) =>
    q.TryGetValue(key, out StringValues v) && v.Count == 1 ? v[0] : null;

Set the postback URL in your app’s Postback tab with the parameter names the endpoint reads. The 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}

Add macros such as {event_id} or {payout} for your logs if you like, but credit only the signed {reward}.

ASP.NET Core details that break postbacks

  • One value per parameter. A query value in ASP.NET Core is a StringValues, which can hold several values when a key repeats; the minimal API parameter binding docs show binding to it. One() rejects a missing or repeated key with 403 before anything is hashed.
  • Results.StatusCode(403), not Results.Forbid(). The Results.Forbid reference says it invokes the authentication handler’s forbid step, and that schemes such as cookies turn the 403 into a redirect to a login page.
  • AllowAnonymous(). If your app sets an authorization fallback policy that requires signed-in users, as Microsoft’s secure data tutorial recommends, every endpoint without its own metadata needs a login. The postback caller has no cookie or token, so mark this one route as public; the HMAC authenticates it.
  • No redirects. Sharklio does not follow redirects. Microsoft’s HTTPS enforcement page says UseHttpsRedirection answers with a 307 by default, so register the https address. Also check rewrite rules and www redirects in your reverse proxy. In our test, routing matched the path with and without a trailing slash.
  • Rate limits and bot rules. If your rate limiter is partitioned by IP, add .DisableRateLimiting() to this route, because every postback comes from Sharklio’s servers. Allow the route in any firewall or bot protection too.
  • Ports and keys. Sharklio only calls public servers on ports 80, 443, 8080 or 8443, so local development ports cannot receive real postbacks. ValidateOnStart() stops the app from starting without its keys, instead of checking signatures against an empty key.

Your signed-in front end calls /api/offerwall-link and opens the URL in an iframe, a WebView or the browser; no-store stops a cache from handing one user’s link to another. The iframe only loads on https pages of the website you registered for the app. For a corner button instead, see Add a floating Earn button to any website.

Credits, reversals and retries

There are only two statuses, and both carry the same {transaction_id} and the same positive {reward}. This is what the handler does with each combination:

Incoming statusRow in sharklio_txWhat the handler doesAnswer
1 (credited)noneInserts it with status 1, adds the reward200
1 (credited)status 1 or 2Nothing200
2 (reversed)status 1Sets 2, takes back the stored reward200
2 (reversed)noneInserts it with status 2, so a late credit cannot pay200
2 (reversed)status 2Nothing200
AnyAny, hash wrong or a parameter missing or repeatedNothing403
AnyDatabase down or another exceptionRolls back500, retried

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. Queue slow work such as emails after CommitAsync. If the last retry fails, Sharklio emails you, at most once a day per app, and you can resend it from Reports, Postbacks; see How to resend a failed postback. Each step of a multi-step offer has its own transaction ID, so it is simply another row.

Unity games with a C# backend

If your Unity game already talks to a backend written in ASP.NET Core, the handler above is the reward side of its offerwall. The game never credits anything; it reloads the balance from your server when the player returns. The client side, with Application.OpenURL or the native plugin, is in How to add an offerwall to a Unity game.

With the offerwall link, the game calls /api/offerwall-link with the player’s session token. With the Sharklio Unity plugin, your server instead creates a single-use session with POST https://api.sharklio.com/v1/session and your API key, and the game passes the token to Sharklio.Open(token). Add this to the same Program.cs:

// with builder.Services.AddHttpClient("sharklio", c =>
//     { c.BaseAddress = new Uri("https://api.sharklio.com/"); c.Timeout = TimeSpan.FromSeconds(10); });
// and using System.Net; using System.Net.Http.Headers; at the top

app.MapPost("/api/offerwall-session", async (ClaimsPrincipal me, IHttpClientFactory http, IOptions<SharklioOptions> options) =>
{
    string userId = me.FindFirstValue(ClaimTypes.NameIdentifier)!;
    using var req = new HttpRequestMessage(HttpMethod.Post, "v1/session")
    {
        Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["user_id"] = userId }),
    };
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.Value.ApiKey);

    using var res = await http.CreateClient("sharklio").SendAsync(req);
    if (res.StatusCode != HttpStatusCode.Created) return Results.StatusCode(503);
    var session = await res.Content.ReadFromJsonAsync<SharklioSession>();
    return Results.Ok(new { token = session!.Token });
}).RequireAuthorization();

record SharklioSession(string Token);

The session docs list the answers: 201 with the token, 400 invalid_user_id, 401 invalid_api_key, 403 app_not_live or user_blocked (do not show the offerwall), and 429 rate_limited above 600 requests a minute per app. A token works once and for 10 minutes, so create one each time the player taps Earn, with the same user ID your postbacks use.

Comparing native offerwall SDKs for Unity? See Best offerwall SDK for Unity games: SDK or web wall?, and for pricing gems or coins, Monetize a mobile game with an offerwall.

Step 5: test before you go live

What we ran before publishing

We ran this code on the .NET 8.0 SDK with the Npgsql EF Core provider 9.0.4 and PostgreSQL 16, with postbacks signed by PHP’s hash_hmac. Credits paid once, duplicates and late credits after a reversal paid nothing, reversals took back once, and 0.315 stayed 0.315. 30 parallel copies of a credit paid once. Wrong, short, uppercase and non-ASCII hashes, a repeated tx and a missing hash got 403, a test_ transaction got 200 with no credit, and a stopped database gave 500. The signed link matched PHP’s. The session endpoint ran against a local mock, not the live API, and we did not test .NET 10 or SQL Server.

  1. Test the crediting path locally with a URL you sign yourself. With SHARKLIO_POSTBACK_KEY exported in your shell and a test user 42 in your database (sample values only; use your local port):
    TX=local_1 U=42 R=150 S=1
    HASH=$(printf %s "$TX:$U:$R:$S" | openssl dgst -sha256 -hmac "$SHARKLIO_POSTBACK_KEY" | awk "{print \$NF}")
    curl -i "http://localhost:5000/sharklio/postback?tx=$TX&user=$U&reward=$R&status=$S&hash=$HASH"
    Run it twice: user 42 gains 150 coins once. Set S=2, recompute the hash, run it twice more: the coins come back off once. Change one character of the hash and you must get 403.
  2. Deploy, then in Integration status on the Integration tab use Check a link and its Test a link tool. Type the user ID your code uses and compare the result with what /api/offerwall-link returns. They must be identical.
  3. Click Send a test postback. Its transaction ID starts with test_ and its offer_id is 0, so the endpoint checks the hash and logs it without crediting anyone. A 200 in the result window means the signature, URL and routing are right.
  4. After going live, every attempt and your server’s status code is listed under Reports, Postbacks. The Testing and troubleshooting docs list the common errors.

Open the wall from a normal home or mobile connection, because VPNs and cloud devices get a block page. The full launch checklist is in How to test your app before it goes live. If a key leaks, see How to rotate your keys and link salt.

C# mistakes that cost real coins

Comparing hashes with ==. Forgetting that Convert.ToHexString is uppercase. Parsing the reward as double or with the server’s culture. Returning Results.Forbid() behind cookie authentication. Running two ExecuteUpdateAsync calls without a transaction. Catching the exception and returning 200 anyway, so a failed credit is never retried. Crediting {payout} or another unsigned macro instead of the signed {reward}.

Versions and reference docs

The code targets .NET 8 or newer. Microsoft’s EF Core releases page lists EF Core 8 and 9 (both on .NET 8) as supported until November 10, 2026, and EF Core 10, which needs .NET 10, until November 10, 2028 (checked on October 21, 2026). The exact macros, statuses and hash format are in the Sharklio Postbacks, Credits and reversals and Offerwall link docs. For the .NET side, see the HMACSHA256.HashData reference and EF Core’s page on transactions. Background: What is server-to-server tracking?

One C# 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 endpoint 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 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 an HMAC-SHA256 hash in C#?

Compute HMACSHA256.HashData(key, message) on the UTF-8 bytes, convert it with Convert.ToHexString and lowercase it, then compare the ASCII bytes of that string with the UTF-8 bytes of the received hash using CryptographicOperations.FixedTimeEquals.

Does CryptographicOperations.FixedTimeEquals throw if the lengths differ?

No. Microsoft’s docs say it returns false when the two spans have different lengths, which is the only case where it short-circuits. A forged hash of any length is simply a mismatch.

Why does every postback get a 403 in ASP.NET Core?

Usually the hash is compared in uppercase, the link hash salt is used instead of the postback secret key, or a value was changed before hashing. A 302 or 401 in Reports, Postbacks means authentication answered before your endpoint.

How do I stop a postback from crediting twice with EF Core?

Make the transaction ID the primary key, insert it with ON CONFLICT DO NOTHING inside BeginTransactionAsync, and add the reward only when the insert affected one row. A retry or duplicate then changes nothing.

Can a Unity game credit the reward itself?

No. Anything inside a game build can be modified, so only your C# backend credits currency, from verified postbacks. The game reloads the balance from your server when the player returns.