Key takeaways

  • Read the query as a MultiValueMap, reject any parameter that is missing or repeated, hash the four signed values unchanged, and compare lowercase hex bytes with MessageDigest.isEqual.
  • Apply each event in one @Transactional method on another bean: lock the user row, check the transaction row, then credit on status 1, take back on status 2 only after a credit, and record unmatched reversals.
  • Give every secret an empty default so a missing environment variable stops the app, map the exact path with @GetMapping, and answer 200 within 6 seconds for every event you handled or chose to ignore.

How do you handle an offerwall postback in Java?

In Spring Boot, an offerwall postback handler is a @GetMapping method that reads the transaction ID, user ID, reward, status and hash exactly once from the query string, recomputes the HMAC-SHA256 of tx:user:reward:status with javax.crypto.Mac and your postback secret key, and compares the lowercase hex with MessageDigest.isEqual. If the hash matches, a @Transactional service using JdbcTemplate adds the reward on status 1 and takes it back on status 2, once per transaction ID. The endpoint answers 403 for a bad hash, 200 for everything it applied or chose to ignore, and 500 when your own code fails, so the event is retried.

Below are four small classes and the SQL for PostgreSQL and MySQL. We built them on Java 17 and sent every request below with curl to Spring Boot 4.1.2 and 3.5.16, on PostgreSQL 18 and MySQL 8.4, on October 22, 2026. Background: Postback security and What is HMAC? The same flow in other languages: Go, C#, Python, Node.js and PHP and Laravel.

Who does what

PartDoesNever does
Browser or appAsks your Spring Boot server for a signed link and opens itHolds the link hash salt or the postback secret key, adds currency
Your Spring Boot 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 the app ID, the link hash salt and the postback secret key. The salt signs links and the key signs postbacks; swapping them gives you a Link not valid page or a 403 on every postback.

Step 1: project and configuration

Any Spring Boot project from Spring Initializr works. You need the web starter, the JDBC starter and the driver for your database. With Maven:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.2</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>

On Spring Boot 3.5 the web starter is spring-boot-starter-web (deprecated in 4). For MySQL, use the com.mysql:mysql-connector-j driver. Spring’s support timeline shows free support for 3.5 ended on June 30, 2026, and 4.1 is supported until July 2027 (checked on October 22, 2026). Every class below compiles unchanged on both.

The three secrets come from environment variables. application.properties:

sharklio.app-id=${SHARKLIO_APP_ID:}
sharklio.link-salt=${SHARKLIO_LINK_SALT:}
sharklio.postback-key=${SHARKLIO_POSTBACK_KEY:}
spring.datasource.hikari.connection-timeout=2000

Spring Boot reads the database settings from SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME and SPRING_DATASOURCE_PASSWORD on its own. The last line caps the wait for a free connection at 2 seconds; the HikariCP README gives a default of 30, far past the 6 seconds Sharklio waits for your answer.

A record holds the values and refuses to start without them. Add @ConfigurationPropertiesScan to your @SpringBootApplication class so Spring picks it up:

package com.example.rewards.sharklio;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("sharklio")
public record SharklioProperties(String appId, String linkSalt, String postbackKey) {
    public SharklioProperties {
        if (isBlank(appId) || isBlank(linkSalt) || isBlank(postbackKey)) {
            throw new IllegalStateException("sharklio.app-id, link-salt and postback-key must be set");
        }
    }

    private static boolean isBlank(String s) {
        return s == null || s.isBlank();
    }
}

A missing variable can become your secret

Keep the empty default (the : in ${SHARKLIO_LINK_SALT:}). In our test, without it, an unset variable did not stop the app: the property was bound as the literal text ${SHARKLIO_LINK_SALT}, and the server signed every link with that public string. With the default, the value is empty, the record throws, and the app fails to start.

Step 2: sign links and verify hashes

One class holds the HMAC, the comparison and the link. It uses only the JDK:

package com.example.rewards.sharklio;

import static java.nio.charset.StandardCharsets.UTF_8;

import java.net.URLEncoder;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class SharklioSigner {
    private SharklioSigner() {}

    /** HMAC-SHA256 of message with key, as lowercase hex. */
    public static String hmacHex(String key, String message) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key.getBytes(UTF_8), "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(message.getBytes(UTF_8)));
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException("HmacSHA256 not available", e);
        }
    }

    /** Constant-time comparison of two hex strings. */
    public static boolean sameHash(String expected, String given) {
        return MessageDigest.isEqual(expected.getBytes(UTF_8), given.getBytes(UTF_8));
    }

    /** Signed offerwall link for one signed-in user. */
    public static String wallUrl(String appId, String linkSalt, String userId) {
        return "https://wall.sharklio.com/" + appId
                + "?user_id=" + URLEncoder.encode(userId, UTF_8)
                + "&hash=" + hmacHex(linkSalt, userId);
    }
}
  • HmacSHA256 is always there. The Mac Javadoc says every Java implementation must support it; the catch exists only for the checked exceptions. The Javadoc promises no thread safety, so each call creates its own Mac.
  • Lowercase hex by default. The HexFormat Javadoc says HexFormat.of() uses lowercase characters and is immutable and thread-safe. Sharklio’s hash is lowercase hex too.
  • MessageDigest.isEqual, not equals. String.equals stops at the first different character, which leaks timing. The MessageDigest Javadoc says isEqual examines all bytes of the first argument and its time depends only on that argument’s length, not on the contents or on the length of the second. That is why the expected hash, whose length is always 64, goes first. A short or empty hash simply returns false.
  • The link matches the docs. URLEncoder.encode turns @ into %40, as PHP’s rawurlencode does in the Offerwall link docs, for every character Sharklio allows in a user ID. The test in Step 6 checks this.

Step 3: one row per transaction ID

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

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

MySQL 8.4 or MariaDB:

CREATE TABLE sharklio_tx (
  transaction_id VARCHAR(191)  NOT NULL PRIMARY KEY,
  user_id        VARCHAR(191)  NOT NULL,
  reward         DECIMAL(19,3) NOT NULL,
  last_status    TINYINT       NOT NULL,
  created_at     TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

The examples credit a coins column of type DECIMAL(19,3) (numeric(19,3) in PostgreSQL) on your users table, with a BIGINT primary key. Three decimals are enough, because the Rewards and currency docs say rewards are rounded to whole numbers or up to 3 decimals. Never use double for balances. On MySQL keep utf8mb4_bin, so TX7 and tx7 stay two different keys; How to prevent duplicate reward credits explains the collation trap and shows a ledger design if you prefer one row per balance change.

Step 4: credit and reverse once, in one transaction

package com.example.rewards.sharklio;

import java.math.BigDecimal;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SharklioPostbacks {
    private static final Logger log = LoggerFactory.getLogger(SharklioPostbacks.class);

    private record Seen(String userId, BigDecimal reward, int lastStatus) {}

    private final JdbcTemplate jdbc;

    public SharklioPostbacks(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional(timeout = 5)
    public void apply(String tx, String user, BigDecimal reward, int status) {
        Long userId = toUserId(user);
        boolean knownUser = userId != null && !jdbc.queryForList(
                "SELECT id FROM users WHERE id = ? FOR UPDATE", Long.class, userId).isEmpty();

        List<Seen> rows = jdbc.query(
                "SELECT user_id, reward, last_status FROM sharklio_tx WHERE transaction_id = ? FOR UPDATE",
                (rs, n) -> new Seen(rs.getString(1), rs.getBigDecimal(2), rs.getInt(3)), tx);
        Seen seen = rows.isEmpty() ? null : rows.get(0);

        if (status == 1 && seen == null) {
            insertTx(tx, user, reward, 1);
            if (knownUser) {
                jdbc.update("UPDATE users SET coins = coins + ? WHERE id = ?", reward, userId);
            } else {
                log.warn("Sharklio credit for unknown user {} (tx {})", user, tx);
            }
        } else if (status == 2 && seen == null) {
            insertTx(tx, user, reward, 2); // reversal of a credit we never applied
        } else if (status == 2 && seen.lastStatus() == 1) {
            jdbc.update("UPDATE sharklio_tx SET last_status = 2 WHERE transaction_id = ?", tx);
            jdbc.update("UPDATE users SET coins = coins - ? WHERE id = ?",
                    seen.reward(), toUserId(seen.userId()));
        }
        // anything else repeats an event that is already applied
    }

    private void insertTx(String tx, String user, BigDecimal reward, int status) {
        jdbc.update("INSERT INTO sharklio_tx (transaction_id, user_id, reward, last_status) VALUES (?, ?, ?, ?)",
                tx, user, reward, status);
    }

    private static Long toUserId(String user) {
        return user.matches("[0-9]{1,18}") ? Long.valueOf(user) : null;
    }
}

Why it is written this way

  • The wallet lock comes first. SELECT ... FOR UPDATE on the user row makes every event for one user wait for the one before it. The MySQL locking reads page says such a read locks rows the same as an UPDATE would and reads the latest available data, not an old snapshot, and the PostgreSQL SELECT docs say a locking clause locks the selected rows against concurrent updates. A second copy of a credit waits, then finds the transaction row and pays nothing.
  • The primary key is the backstop. A check followed by an insert is not enough on its own, as the duplicate credits guide explains. If two copies ever get past the check together (only possible for a user who does not exist, so there is no wallet row to lock), the second insert fails on the key, the whole transaction rolls back, and the retry answers 200.
  • Plain, portable SQL. No ON CONFLICT or INSERT IGNORE, so the same statements run on PostgreSQL and MySQL, and JdbcTemplate binds every value as a parameter.
  • Rollback on any error. The Spring @Transactional docs say any RuntimeException triggers a rollback, and every JdbcTemplate failure is one.
  • A 5 second limit. The JdbcTemplate Javadoc says the remaining transaction timeout overrides the statement timeout. In our test, a credit blocked by a lock held in another session failed after 5.0 seconds with QueryTimeoutException on both databases, so your server answers 500 inside Sharklio’s 6 seconds and the retry succeeds later.
  • Reversals use the stored values. Status 2 takes back the reward saved with the credit. A reversal you never credited is stored as 2, so a late copy of that credit cannot pay afterwards.
  • Call it from another bean. The same Spring page says that in the default proxy mode only external calls are intercepted, so the controller calls apply through the injected bean, never from inside SharklioPostbacks.

The code assumes numeric user IDs; for UUIDs, change toUserId and the column types. If the user has already spent the coins, a reversal pushes the balance below zero. Allow negative values (no CHECK (coins >= 0)), or every such reversal fails, retries five times and leaves the credit in place. You can hold withdrawals until the balance recovers, as long as your terms say so; the Credits and reversals docs explain why results get reversed.

Step 5: the GET controller

package com.example.rewards.sharklio;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.SessionAttribute;

@RestController
public class SharklioController {
    private static final Logger log = LoggerFactory.getLogger(SharklioController.class);

    private final SharklioProperties props;
    private final SharklioPostbacks postbacks;

    public SharklioController(SharklioProperties props, SharklioPostbacks postbacks) {
        this.props = props;
        this.postbacks = postbacks;
    }

    @GetMapping("/sharklio/postback")
    public ResponseEntity<Void> postback(@RequestParam MultiValueMap<String, String> query) {
        String tx = single(query, "tx");
        String user = single(query, "user");
        String reward = single(query, "reward");
        String status = single(query, "status");
        String hash = single(query, "hash");
        if (tx == null || user == null || reward == null || status == null || hash == null) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }

        String expected = SharklioSigner.hmacHex(props.postbackKey(),
                tx + ":" + user + ":" + reward + ":" + status);
        if (!SharklioSigner.sameHash(expected, hash)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }

        if (tx.startsWith("test_")) {
            log.info("Sharklio test postback tx={} user={} status={}", tx, user, status);
            return ResponseEntity.ok().build();
        }
        if (status.equals("1") || status.equals("2")) {
            postbacks.apply(tx, user, new BigDecimal(reward), Integer.parseInt(status));
        }
        return ResponseEntity.ok().build();
    }

    @GetMapping("/api/offerwall-link")
    public ResponseEntity<Map<String, String>> offerwallLink(
            @SessionAttribute(name = "userId", required = false) Long userId) {
        if (userId == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        String url = SharklioSigner.wallUrl(props.appId(), props.linkSalt(), userId.toString());
        return ResponseEntity.ok()
                .cacheControl(CacheControl.noStore().cachePrivate())
                .body(Map.of("url", url));
    }

    /** The value of a parameter that appears exactly once, otherwise null. */
    private static String single(MultiValueMap<String, String> query, String name) {
        List<String> values = query.get(name);
        return values != null && values.size() == 1 ? values.get(0) : null;
    }
}

Read each parameter exactly once

The @RequestParam docs say a MultiValueMap<String, String> argument without a name is filled with every value of every parameter. single then accepts a parameter only when it appears exactly once. That matters because the shortcut is quietly wrong: in our test, a plain @RequestParam String tx turned ?tx=a&tx=b into the single string a,b. The values then go into the HMAC exactly as they arrived. Parsing the reward first would turn 150.50 into 150.5 and break the hash, so new BigDecimal runs only after the check.

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

Routing, redirects and Spring Security

  • HEAD and 405. The request mapping docs say @GetMapping supports HEAD transparently. In our test, a POST got 405 with Allow: GET.
  • No trailing slash. The Spring Framework 6.0 release notes say trailing slash matching was deprecated and turned off by default. /sharklio/postback/, /sharklio//postback and /Sharklio/postback all got 404 in our test, on both Boot versions. Put the exact path in your Postback tab.
  • No redirects. Sharklio does not follow them, so enter the https address if your proxy redirects http to https.
  • Spring Security. The Spring Boot security docs say that once Spring Security is on the classpath, web applications are secured by default. Permit this one path without login in your security filter chain (CSRF protection covers unsafe methods such as POST, not this GET). Skip per-IP rate limits too: every postback comes from Sharklio’s servers, and the hash protects it.

Set the postback URL in your app’s Postback tab with the macros the controller reads:

https://yoursite.com/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}

You can add {event_id}, {offer_name} or {sub1} 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 Spring Boot’s default 8080 works directly, or behind nginx or a load balancer on 443.

The second method serves the signed link. @SessionAttribute("userId") stands for your own login: read the user ID however your app stores it. Your front end or mobile app calls /api/offerwall-link and opens the URL in an iframe, a WebView or the browser (see how to open the offerwall in a WebView), and no-store stops a cache from handing one user’s link to another. For a native screen in an Android app, the Android SDK uses a session token from your server instead; see the Kotlin offerwall guide.

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 statusRow in sharklio_txWhat apply does
1 (credited)noneStores 1, adds the reward
1 (credited)1 or 2Nothing, answers 200
2 (reversed)1Stores 2, takes back the stored reward
2 (reversed)noneStores 2 so a late credit cannot pay, answers 200
2 (reversed)2Nothing, answers 200

Each step of a multi-step offer is its own result with its own transaction ID. 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 after the commit (a @TransactionalEventListener or a queue), 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.

Java mistakes that cost real coins

Comparing hashes with equals. Reading values with @RequestParam String or getParameter, which join or drop a repeated value. Parsing the reward before hashing. Calling a @Transactional method from the same class. Catching DataAccessException and returning 200, which tells Sharklio to stop retrying an event you never applied. And crediting {payout} or another unsigned macro instead of the signed {reward}.

Step 6: test with JUnit and curl

The signing code needs no Spring context. This JUnit 5 test checks the HMAC against test case 2 of RFC 4231, the comparison, and the link against the value PHP’s hash_hmac gives for the same input:

package com.example.rewards.sharklio;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

class SharklioSignerTest {

    @Test
    void matchesRfc4231TestCase2() {
        assertEquals("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
                SharklioSigner.hmacHex("Jefe", "what do ya want for nothing?"));
    }

    @Test
    void comparesHashes() {
        String good = SharklioSigner.hmacHex("key", "tx1:42:150:1");
        assertTrue(SharklioSigner.sameHash(good, good));
        assertFalse(SharklioSigner.sameHash(good, good.toUpperCase()));
        assertFalse(SharklioSigner.sameHash(good, good.substring(1)));
        assertFalse(SharklioSigner.sameHash(good, ""));
    }

    @Test
    void buildsTheSameLinkAsTheDocs() {
        assertEquals("https://wall.sharklio.com/APP?user_id=user%40example.com"
                + "&hash=c1a73bdf10becba29511e1076bcc4d00f9c690d98ccc9cf7a35a75b8dbdf069f",
                SharklioSigner.wallUrl("APP", "salt", "[email protected]"));
    }
}

Run it with ./mvnw test. Then start the app (./mvnw spring-boot:run) and send signed requests with this script, which builds the hash the same way Sharklio does:

#!/bin/bash
# usage: ./postback-test.sh tx user reward status
KEY="$SHARKLIO_POSTBACK_KEY"
tx=$1; user=$2; reward=$3; status=$4
hash=$(printf '%s' "$tx:$user:$reward:$status" | openssl dgst -sha256 -hmac "$KEY" | awk '{print $2}')
curl -s -o /dev/null -w "%{http_code}\n" \
  "http://localhost:8080/sharklio/postback?tx=$tx&user=$user&reward=$reward&status=$status&hash=$hash"

What our runs returned, with user 42 starting at 0 coins (sample run, your IDs will differ):

RequestAnswerBalance effect
tx1 42 150.5 1, then the same again200, 200+150.5 once
20 parallel copies of tx2 42 10 120 times 200+10 once
tx1 42 150.5 2 twice, then tx1 ... 1200, 200, 200-150.5 once, no new credit
Reversal of tx3 before any credit, then its credit200, 200None
test_abc 42 100 1200None, logged only
Wrong or uppercase hash, tx twice, no hash, reward=5.0 signed as 5403None
Path with trailing slash, double slash or capital S404None
POST405None
Credit while another session held the user row locked500 after 5 seconds, 200 on retry+1 once

Both databases ended with the right balances. The only other 500s came from 20 parallel credits for a user ID that does not exist (one duplicate key on PostgreSQL, two deadlocks on MySQL), rolled back with no balance change; a retry answers 200.

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 right away, as described in How to rotate your keys and link salt. The exact macros, statuses and hash format are in the Sharklio Postbacks docs, which the code above follows.

One Spring Boot endpoint 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 controller 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 is reviewed by hand. See how the wall works, or get in touch.

Frequently asked questions

How do I verify an HMAC-SHA256 signature in Java?

Create Mac.getInstance("HmacSHA256"), initialize it with a SecretKeySpec of your key, call doFinal on the message bytes and hex-encode the result with HexFormat.of(). Compare it with the received value using MessageDigest.isEqual on the bytes, never with equals.

Is MessageDigest.isEqual a constant-time compare?

Yes, for the contents. Its Javadoc says the time depends only on the length of the first argument, so pass the expected 64-character hash first.

How do I create a webhook or postback endpoint in Spring Boot?

Add a @RestController with a @GetMapping method for the exact path, read the query with @RequestParam MultiValueMap<String, String>, verify the signature, and return a ResponseEntity with 200, 403 or let errors become 500. Keep it open to anonymous requests if you use Spring Security.

Why does @RequestParam give me a comma in the value?

When a parameter appears twice, a String argument receives both values joined with a comma, for example a,b. Read the parameters as a MultiValueMap and reject any that appear more than once.

How do I stop a postback from crediting twice in Spring Boot?

Keep one row per transaction ID with a primary key, and apply each event in one @Transactional method that locks the user row with SELECT ... FOR UPDATE before it reads that row. A retry or duplicate then finds the status already set and changes nothing.

Does this work with Spring Boot 3?

Yes. The same classes ran on Spring Boot 3.5.16 and 4.1.2; only the web starter name differs. Spring’s free support for 3.5 ended on June 30, 2026, so new projects should start on 4.1.