Offerwall Postback in Go: net/http and HMAC

Key takeaways

  • Read the query once with r.URL.Query(), reject any parameter that is missing or repeated, hash the four values exactly as they arrive, and compare with hmac.Equal.
  • Register an exact method pattern such as "GET /sharklio/postback" and put the same path in your postback URL, because Sharklio does not follow redirects.
  • Lock one row per transaction ID inside a database/sql transaction: credit on status 1, take back on status 2 only after a credit, record unmatched reversals, and answer 500 only for your own errors.

How do you handle an offerwall postback in Go?

Register a GET handler on a Go 1.22+ http.ServeMux, read the transaction ID, user ID, reward, status and hash once from r.URL.Query(), recompute the HMAC-SHA256 of tx:user:reward:status with crypto/hmac and your postback secret key, and compare the hex strings with hmac.Equal. If they match, apply the event in one database/sql transaction that locks a 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 applied or ignored, 403 for a bad hash, and 500 when your own code fails, so the event is retried.

This guide gives you that as one small package plus a main.go, using only the standard library and the pgx driver for PostgreSQL. We compiled it and ran the tests below with Go 1.27 against PostgreSQL 18 on October 21, 2026. The reasons behind each check are in Postback security: verify every reward call, and the basics in What is a postback URL?. The same flow in other languages: Node.js, Python, C# and PHP and Laravel.

Who does what

PartDoesNever does
Browser or appOpens the signed link it got from your Go serverHolds the link hash salt or the postback secret key, adds currency
Your Go serverSigns the link, verifies postbacks, owns the balancesChanges a balance for anything except 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 three values: 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 swapping them gives you a Link not valid page or a 403 on every postback. Keep all three in environment variables (SHARKLIO_APP_ID, SHARKLIO_LINK_SALT, SHARKLIO_POSTBACK_KEY) next to DATABASE_URL.

Step 1: one row per transaction ID

Every event of one result carries the same {transaction_id}. A table keyed on it, holding the last status you applied, is what makes retries and duplicates harmless:

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

The examples credit a coins column of type numeric on your users table. Keep both columns numeric, not float8, so a reward such as 0.296 stays exact, and allow the balance to go below zero (no CHECK (coins >= 0)), or a reversal of coins the user already spent fails on every retry.

Step 2: the sharklio package

One file holds the link signing, the hash check and the crediting. It imports only the standard library: crypto/hmac, crypto/sha256, encoding/hex, net/url, database/sql and log/slog.

// Package sharklio signs offerwall links and handles Sharklio postbacks.
package sharklio

import (
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "database/sql"
    "encoding/hex"
    "log/slog"
    "net/http"
    "net/url"
    "strings"
    "time"
)

func sign(key []byte, msg string) string {
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(msg))
    return hex.EncodeToString(mac.Sum(nil))
}

// WallURL builds the signed offerwall link for one signed-in user.
func WallURL(appID string, linkSalt []byte, userID string) string {
    return "https://wall.sharklio.com/" + appID +
        "?user_id=" + url.QueryEscape(userID) +
        "&hash=" + sign(linkSalt, userID)
}

// Postback is the http.Handler for the postback URL.
type Postback struct {
    DB  *sql.DB
    Key []byte // postback secret key, not the link hash salt
}

type event struct{ TX, User, Reward, Status, Hash string }

// parse returns ok=false unless every parameter appears exactly once.
func parse(q url.Values) (e event, ok bool) {
    for name, dst := range map[string]*string{
        "tx": &e.TX, "user": &e.User, "reward": &e.Reward,
        "status": &e.Status, "hash": &e.Hash,
    } {
        v := q[name]
        if len(v) != 1 {
            return event{}, false
        }
        *dst = v[0]
    }
    return e, true
}

func (p *Postback) valid(e event) bool {
    want := sign(p.Key, e.TX+":"+e.User+":"+e.Reward+":"+e.Status)
    return hmac.Equal([]byte(want), []byte(e.Hash))
}

func (p *Postback) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    e, ok := parse(r.URL.Query())
    if !ok || !p.valid(e) {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }
    if strings.HasPrefix(e.TX, "test_") {
        slog.Info("sharklio test postback", "tx", e.TX, "status", e.Status)
        w.WriteHeader(http.StatusOK)
        return
    }
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()
    if err := p.apply(ctx, e); err != nil {
        slog.Error("sharklio postback failed", "tx", e.TX, "err", err)
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusOK)
}

func (p *Postback) apply(ctx context.Context, e event) error {
    if e.Status != "1" && e.Status != "2" {
        return nil
    }
    tx, err := p.DB.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // no-op after a successful Commit

    if _, err := tx.ExecContext(ctx,
        `INSERT INTO sharklio_tx (transaction_id, user_id, reward)
         VALUES ($1, $2, $3) ON CONFLICT (transaction_id) DO NOTHING`,
        e.TX, e.User, e.Reward); err != nil {
        return err
    }
    var userID, reward string
    var last sql.NullInt16
    if err := tx.QueryRowContext(ctx,
        `SELECT user_id, reward, last_status FROM sharklio_tx
         WHERE transaction_id = $1 FOR UPDATE`, e.TX).Scan(&userID, &reward, &last); err != nil {
        return err
    }

    switch {
    case e.Status == "1" && !last.Valid:
        res, err := tx.ExecContext(ctx,
            `UPDATE users SET coins = coins + $1 WHERE id = $2`, e.Reward, e.User)
        if err != nil {
            return err
        }
        if n, _ := res.RowsAffected(); n == 0 {
            slog.Warn("sharklio credit for unknown user", "user", e.User, "tx", e.TX)
        }
    case e.Status == "2" && last.Valid && last.Int16 == 1:
        if _, err := tx.ExecContext(ctx,
            `UPDATE users SET coins = coins - $1 WHERE id = $2`, reward, userID); err != nil {
            return err
        }
    case e.Status == "2" && !last.Valid:
        // Reversal of a credit we never applied: record it so a late credit cannot pay.
    default:
        return nil // repeat of an event already applied
    }

    if _, err := tx.ExecContext(ctx,
        `UPDATE sharklio_tx SET last_status = $1, updated_at = now() WHERE transaction_id = $2`,
        e.Status, e.TX); err != nil {
        return err
    }
    return tx.Commit()
}

Why it is written this way

  • The query is parsed once. r.URL.Query() parses the raw query again on every call, so the handler calls it once and works on that map. The net/url docs say it silently discards malformed pairs, and that Values.Get returns only the first value of a key. That is why parse reads the map directly and rejects a parameter that is missing or appears twice, such as ?tx=a&tx=b, before anything is hashed.
  • No FormValue. r.FormValue also returns only the first value, and on POST, PUT and PATCH it prefers a form body over the query string. Read the query map yourself.
  • Values are hashed unchanged. The reward goes into the HMAC exactly as it arrived. Parsing it with strconv.ParseFloat first would change 150.50 to 150.5 and break the signature, and floats have no place in balances anyway.
  • hmac.Equal on bytes. The crypto/hmac docs say Equal compares two MACs without leaking timing information, and tell receivers to use it to avoid timing side channels. Unlike Node.js’s timingSafeEqual, it does not panic when the lengths differ: it returns false, so a short or garbled hash is a clean 403. Comparing the lowercase hex strings also matches Sharklio’s format exactly.
  • One transaction, one row lock. ON CONFLICT DO NOTHING creates the row on the first event, and SELECT ... FOR UPDATE locks it. The PostgreSQL SELECT docs say a locking clause locks the selected rows against concurrent updates, so a second copy of the same event waits, then sees last_status already 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.
  • Rollback by default. defer tx.Rollback() undoes everything on any early return, and does nothing after a successful Commit. The database/sql docs say that if the context passed to BeginTx is canceled, the transaction is rolled back. The handler uses the request context with a 5 second limit, so a stuck database cannot hold the call past Sharklio’s 6 seconds, and a cut connection leaves nothing half-applied for the retry to trip over.
  • Rewards stay strings. pgx sends a Go string argument to PostgreSQL in text format, so the database parses 150.5 into numeric itself, and the same goes for the user ID against a bigint or uuid key.

Test postbacks from the dashboard always have a transaction ID that starts with test_ and offer_id 0. The handler checks their hash and logs them without touching the database. Delete that branch if you would rather credit a test user and take the coins back by hand.

Step 3: wire it into net/http

Since Go 1.22, ServeMux patterns can include a method. The Go 1.22 release notes say registering a handler with "GET" also registers it for HEAD, and the Go blog post on routing enhancements says a request that matches the path but not the method gets 405 Method Not Allowed with an Allow header. The current pgx v5 release declares Go 1.25 in its go.mod, so in practice you need Go 1.25 or newer.

package main

import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"
    "os"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"

    "example.com/postback/sharklio"
)

func mustEnv(name string) string {
    v := os.Getenv(name)
    if v == "" {
        log.Fatalf("%s is not set", name)
    }
    return v
}

func main() {
    appID := mustEnv("SHARKLIO_APP_ID")
    linkSalt := []byte(mustEnv("SHARKLIO_LINK_SALT"))

    db, err := sql.Open("pgx", mustEnv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    db.SetMaxOpenConns(10)

    mux := http.NewServeMux()
    mux.Handle("GET /sharklio/postback", &sharklio.Postback{
        DB:  db,
        Key: []byte(mustEnv("SHARKLIO_POSTBACK_KEY")),
    })
    mux.HandleFunc("GET /api/offerwall-link", func(w http.ResponseWriter, r *http.Request) {
        userID, ok := currentUserID(r)
        if !ok {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        w.Header().Set("Cache-Control", "private, no-store")
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{
            "url": sharklio.WallURL(appID, linkSalt, userID),
        })
    })

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }
    log.Fatal(srv.ListenAndServe())
}
  • currentUserID stands for your own session lookup. Your front end or mobile app calls /api/offerwall-link, gets the URL, and opens it in an iframe, a WebView or the system browser. no-store stops a cache from handing one user’s signed link to another.
  • The blank import registers the "pgx" driver name, as the pgx stdlib docs show. *sql.DB is a connection pool that is safe to share, so open it once in main.
  • mustEnv stops the server at startup when a key is missing, instead of signing with an empty secret.
  • ReadHeaderTimeout and WriteTimeout stop slow clients from tying up connections. Keep WriteTimeout above the 5 second database limit so a 500 still gets written.

Set the postback URL in your app’s Postback tab with the macros the package 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 {event_id}, {offer_name}, {payout}, {sub1} or {integration} for your logs, but only the four signed values decide what you credit. Sharklio calls ports 80, 443, 8080 or 8443 on a public server, so the :8080 listener works directly, or behind nginx or a load balancer on 443.

Redirects and trailing slashes

Sharklio does not follow redirects, so a 3xx counts as a failure. The ServeMux docs list three traps:

  • A pattern ending in a slash is a subtree. If you register "GET /sharklio/postback/", a request without the slash is redirected to add it. Register the path without the slash, as above, and a request with an extra slash simply gets 404, which you will see in Reports, Postbacks.
  • Paths are cleaned. A request with repeated slashes or . segments is redirected to a clean URL, so type the path once, without //.
  • Literal parts are case-sensitive. /Sharklio/postback is a 404 for the pattern above.

Also enter the https address if your proxy redirects http to https. On chi, Gin or Echo, Postback is a plain http.Handler, so register it with r.Method("GET", path, pb), gin.WrapH(pb) or echo.WrapHandler(pb). Gin turns on RedirectTrailingSlash by default, which is one more reason to copy the path exactly. Keep the route outside login middleware, CSRF checks and per-IP rate limits: every postback comes from Sharklio’s servers, and the hash is what protects it.

Credits, reversals and retries

There are only two statuses, and both carry the same positive {reward}. The status tells you whether to add or take back:

Incoming statusLast status in your tableWhat apply does
1 (credited)noneAdds the reward, stores 1
1 (credited)1 or 2Nothing, answers 200
2 (reversed)1Takes back the stored reward, stores 2
2 (reversed)noneStores 2 so a late credit cannot pay, answers 200
2 (reversed)2Nothing, answers 200

An offer with several steps sends each step as its own result with its own transaction ID, so each step is another row. If the user has already spent the coins, the balance goes below zero; you can instead hold withdrawals until it recovers, as long as your terms allow it. The Credits and reversals docs explain why results get reversed, and Publisher earnings, settlement, 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. Send emails or push notifications from a goroutine or queue after Commit, not before the answer. If the last retry fails, Sharklio emails you at most once a day per app, and you can send the postback again from your dashboard; see How to resend a failed postback.

Go mistakes that cost real coins

Comparing hashes with == instead of hmac.Equal. Reading values with q.Get or r.FormValue, which quietly take the first of two. Parsing the reward to float64 before hashing. Running statements on db instead of tx inside the transaction, so the lock and the update land on different connections. Writing the 200 before Commit. And crediting {payout} or another unsigned macro instead of the signed {reward}.

Step 4: test it with httptest

The checks before the database can run in a plain unit test. httptest.NewRecorder captures the answer, and serving through a real ServeMux also proves the routing:

package sharklio

import (
    "net/http"
    "net/http/httptest"
    "net/url"
    "testing"
)

var key = []byte("test-postback-key")

func signedQuery(tx, user, reward, status string) url.Values {
    return url.Values{
        "tx": {tx}, "user": {user}, "reward": {reward}, "status": {status},
        "hash": {sign(key, tx+":"+user+":"+reward+":"+status)},
    }
}

func call(t *testing.T, method, target string) int {
    t.Helper()
    mux := http.NewServeMux()
    mux.Handle("GET /sharklio/postback", &Postback{Key: key}) // no DB: these cases never reach it
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
    return rec.Code
}

func TestPostbackRejects(t *testing.T) {
    q := signedQuery("test_1", "42", "150", "1")
    bad := signedQuery("test_1", "42", "150", "1")
    bad.Set("hash", "0"+bad.Get("hash")[1:])
    twice := signedQuery("test_1", "42", "150", "1")
    twice.Add("tx", "x")

    cases := []struct {
        name, method, target string
        want                 int
    }{
        {"signed test event", "GET", "/sharklio/postback?" + q.Encode(), 200},
        {"wrong hash", "GET", "/sharklio/postback?" + bad.Encode(), 403},
        {"repeated tx", "GET", "/sharklio/postback?" + twice.Encode(), 403},
        {"no hash", "GET", "/sharklio/postback?tx=test_1", 403},
        {"trailing slash", "GET", "/sharklio/postback/?" + q.Encode(), 404},
        {"POST", "POST", "/sharklio/postback?" + q.Encode(), 405},
    }
    for _, c := range cases {
        if got := call(t, c.method, c.target); got != c.want {
            t.Errorf("%s: got %d, want %d", c.name, got, c.want)
        }
    }
}

func TestWallURL(t *testing.T) {
    got := WallURL("APP", []byte("salt"), "[email protected]")
    want := "https://wall.sharklio.com/APP?user_id=user%40example.com&hash=" + sign([]byte("salt"), "[email protected]")
    if got != want {
        t.Errorf("got %s", got)
    }
}

Run it with go test ./.... For the database path, run the handler against a throwaway PostgreSQL database and send the same signed event twice: the user gains the reward once. Then send status 2 twice and it comes off once, and a credit after that pays nothing. Our run also fired 20 copies of one credit at the same time and the user was credited once.

Then test the real thing:

  1. In Integration status on the Integration tab, use Check a link and its Test a link tool. Type a user ID and compare its link with what WallURL builds. They must be identical.
  2. Click Send a test postback. Its transaction ID starts with test_, so your server logs it and answers 200.
  3. 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 and Offerwall link docs, which the code above follows. The Go and PostgreSQL behavior is from the official pages linked in each section (checked on October 21, 2026). For why rewards travel server to server at all, read What is server-to-server tracking?, and to open the wall from a web page, see Add a floating Earn button to any website.

One Go handler for every Sharklio integration

Sharklio is a pay-per-result affiliate network. Every credit and every reversal reaches your server as a signed server-to-server postback, 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 package above serves all of them. Each app sets its own currency name, conversion rate, user split and rounding, and {reward} arrives already converted. Your dashboard and Reporting API show clicks, conversions and reversals by country, offer and sub ID. 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 signature in Go?

Create hmac.New(sha256.New, key), write the message, encode mac.Sum(nil) with hex.EncodeToString, and compare it with the received value using hmac.Equal([]byte(want), []byte(got)). Never use == for signatures.

Does hmac.Equal work if the two values have different lengths?

Yes. It returns false without panicking, so a short or garbled hash simply fails the check and your handler answers 403.

How do I restrict a Go route to GET requests?

On Go 1.22 or newer, register the pattern with a method, such as mux.Handle("GET /sharklio/postback", h). It also matches HEAD, and other methods get 405 Method Not Allowed.

Why does my Go postback URL return a redirect?

Usually a trailing slash: a pattern ending in / redirects requests without it, and ServeMux also redirects paths with //. Register the exact path, put that same path in your Postback tab, and use the https address.

How do I stop a postback from crediting twice in Go?

Store the last status per transaction ID and lock that row with SELECT ... FOR UPDATE inside a database/sql transaction, running every statement on the *sql.Tx. A retry or a duplicate then finds the status already set and changes nothing.

Can I use Gin, chi or Echo instead of net/http?

Yes. The handler is a standard http.Handler, so wrap it with gin.WrapH or echo.WrapHandler, or pass it to chi directly, and register the exact path so no trailing slash redirect sits in front of it.