Key takeaways

  • Read the five query values once from request.query_parameters, reject anything that is not a plain string, and compare OpenSSL::HMAC.hexdigest with ActiveSupport::SecurityUtils.secure_compare before you touch the database.
  • A unique index on transaction_id is the credit gate: insert the row and add the coins in one transaction, and rescue ActiveRecord::RecordNotUnique outside the block, never inside it.
  • Subclass ActionController::API for the postback so CSRF, allow_browser and the Rails 8 sign-in filter never get in the way, use the https URL because production forces SSL, and answer 200 within 6 seconds.

How do you handle an offerwall postback in Ruby on Rails?

In Rails, an offerwall postback handler is a GET route to a small controller that reads tx, user, reward, status and hash once from the query string, recomputes OpenSSL::HMAC.hexdigest("SHA256", key, "tx:user:reward:status") with your postback secret key, and compares it with ActiveSupport::SecurityUtils.secure_compare. If it matches, it credits status 1 or takes back status 2 once per transaction ID, guarded by a unique index, and answers 200; a bad signature gets 403.

Below: a migration, one module, one model and one controller. We ran every snippet with Ruby 3.4 and Rails 8.1.4 on PostgreSQL 18 and SQLite on October 22, 2026. Why each check matters is in Postback security: verify every reward call; the same flow in other languages is in Python, Go, Java and Node.js.

Who does what

Your Rails app signs the offerwall link for the signed-in user, receives postbacks, and is the only place that changes balances. The browser only opens the finished link and never sees a secret. Sharklio sends one signed GET request per event: status 1 when a result is credited, status 2 if it is reversed later, both with the same transaction_id and a positive amount. Rails defaults that matter here:

TopicRails 8 defaultWhat to do for the postback
CSRFActionController::Base checks tokens on every request except GET and HEADNothing for GET; subclass ActionController::API anyway
Browser filterNew apps add allow_browser versions: :modern to ApplicationControllerNot inherited by an API controller
Sign-inThe authentication generator adds before_action :require_authenticationNot inherited; with Base you would need allow_unauthenticated_access
HTTPSconfig.force_ssl = true in productionEnter the https URL, because an http request gets a redirect
Trailing slashRoutes match with or without itNo redirect trap, unlike Django
Credit onceA unique index raises ActiveRecord::RecordNotUniqueRescue it outside the transaction block

Step 1: one row per transaction ID

From your app’s Integration tab you need the app ID in your offerwall link, the link hash salt, and the postback secret key. Keep them in environment variables or in Rails.application.credentials. Then create the table with bin/rails generate migration CreateSharklioTransactions and fill it in:

class CreateSharklioTransactions < ActiveRecord::Migration[8.1]
  def change
    create_table :sharklio_transactions do |t|
      t.string :transaction_id, null: false, limit: 100
      t.string :wall_user_id, null: false, limit: 100
      t.decimal :reward, precision: 16, scale: 3, null: false
      t.integer :last_status, null: false, limit: 2
      t.timestamps
    end
    add_index :sharklio_transactions, :transaction_id, unique: true

    add_column :users, :coins, :decimal, precision: 16, scale: 3, null: false, default: 0
  end
end

The unique index makes a duplicated postback harmless whatever your Ruby code does. Use decimal, not float, so 0.296 stays exact, and let coins go negative: a reversal of coins the user already spent must still apply.

Step 2: sign the link and the postback in one module

New Rails apps autoload lib/ through config.autoload_lib, so this file is available everywhere as Sharklio:

# lib/sharklio.rb
require "openssl"

module Sharklio
  APP_ID = ENV.fetch("SHARKLIO_APP_ID")
  LINK_SALT = ENV.fetch("SHARKLIO_LINK_SALT")
  POSTBACK_KEY = ENV.fetch("SHARKLIO_POSTBACK_KEY")

  def self.sign(key, message)
    OpenSSL::HMAC.hexdigest("SHA256", key, message)
  end

  def self.wall_url(user_id)
    uid = user_id.to_s
    "https://wall.sharklio.com/#{APP_ID}?user_id=#{ERB::Util.url_encode(uid)}&hash=#{sign(LINK_SALT, uid)}"
  end

  def self.valid_postback?(tx, user, reward, status, given)
    expected = sign(POSTBACK_KEY, "#{tx}:#{user}:#{reward}:#{status}")
    ActiveSupport::SecurityUtils.secure_compare(expected, given)
  end
end
  • ENV.fetch fails loudly. Production eager-loads, so a missing key stops the app at boot instead of accepting signatures made with an empty key.
  • The output is lowercase hex. Ruby’s OpenSSL::HMAC docs say hexdigest returns a hex-encoded string, the format the Offerwall link and Postbacks docs expect. Our values matched PHP’s hash_hmac exactly.
  • The comparison does not leak timing. The ActiveSupport::SecurityUtils reference describes secure_compare as a secure comparison for strings of variable length.
  • Two different secrets. The salt signs links; the key signs postbacks. Swapping them gives a Link not valid page or a 403 on every postback.

ERB::Util.url_encode encodes @ and + like PHP’s rawurlencode. 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 the primary key works well. What is HMAC? explains both signatures.

The earn page

# config/routes.rb
get "earn", to: "earn#show"

# app/controllers/earn_controller.rb
class EarnController < ApplicationController
  def show
    no_store
    @wall_url = Sharklio.wall_url(Current.user.id)
  end
end
<%# app/views/earn/show.html.erb %>
<h1>Earn coins</h1>
<iframe title="Earn coins" src="<%= @wall_url %>"
  style="border:0;width:100%;height:100vh"></iframe>

Current.user comes from the Rails 8 authentication generator (use current_user with Devise). no_store sends Cache-Control: no-store, so no cache serves one user’s signed link to another. ERB escapes the & to &amp;, which is correct in an attribute, so skip raw and html_safe. If your content security policy initializer is on, add policy.frame_src :self, "https://wall.sharklio.com". For a corner button instead, see Add a floating Earn button to any website.

Step 3: the route and the postback controller

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

https://yoursite.com/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
# config/routes.rb
get "sharklio/postback", to: "sharklio_postbacks#show"

# app/controllers/sharklio_postbacks_controller.rb
class SharklioPostbacksController < ActionController::API
  def show
    q = request.query_parameters
    tx, user, reward, status, given = q.values_at("tx", "user", "reward", "status", "hash")

    unless [tx, user, reward, status, given].all?(String) &&
           Sharklio.valid_postback?(tx, user, reward, status, given)
      return head :forbidden
    end

    if tx.start_with?("test_")
      Rails.logger.info("Sharklio test postback tx=#{tx} status=#{status}")
      return head :ok
    end

    case status
    when "1" then SharklioTransaction.credit!(tx, user, BigDecimal(reward))
    when "2" then SharklioTransaction.reverse!(tx, user, BigDecimal(reward))
    end
    head :ok
  end
end

Why it is written this way:

  1. The query is read once. request.query_parameters holds only the query string, so no body or route value can swap in between the check and the credit. A repeated key keeps its last value in both places, so a tampered copy just fails the hash.
  2. Only plain strings pass. Rails turns tx[]=1 into an array and tx[a]=1 into a hash, and a missing key is nil. all?(String) turns all three into a 403 instead of a 500.
  3. Values are hashed exactly as they arrive. BigDecimal(reward) comes after the check, because 315.0 would not match 315.
  4. Tests never touch balances. Test postbacks have a transaction ID starting with test_, and any unexpected error becomes a 500 that Sharklio retries.

protect_from_forgery and GET

The RequestForgeryProtection reference says all requests are checked except GET requests, and that API-only applications do not include the module at all. So a GET postback passes CSRF even on ActionController::Base, and you never need skip_forgery_protection here. The same page says GET requests should not write to the database; that advice is about browser requests riding on a user’s cookies, while this one has no cookies and is authenticated by an HMAC an attacker cannot produce.

We still use ActionController::API because ApplicationController carries filters meant for people. In our test, allow_browser versions: :modern let the Sharklio-Postback/1.0 agent through, but the generator’s require_authentication redirects to the sign-in page, and Sharklio does not follow redirects. A controller with no inherited filters cannot break when someone adds one later.

Step 4: credit once with RecordNotUnique

# app/models/sharklio_transaction.rb
class SharklioTransaction < ApplicationRecord
  CREDITED = 1
  REVERSED = 2

  def self.credit!(tx, user_id, amount)
    transaction do
      create!(transaction_id: tx, wall_user_id: user_id, reward: amount, last_status: CREDITED)
      if User.where(id: user_id).update_all(["coins = coins + ?", amount]).zero?
        Rails.logger.warn("Sharklio credit for unknown user #{user_id} (tx #{tx})")
      end
    end
    true
  rescue ActiveRecord::RecordNotUnique
    false
  end

  def self.reverse!(tx, user_id, amount)
    transaction do
      flipped = where(transaction_id: tx, last_status: CREDITED)
                  .update_all(last_status: REVERSED, updated_at: Time.current)
      if flipped == 1
        row = find_by!(transaction_id: tx)
        User.where(id: row.wall_user_id).update_all(["coins = coins - ?", row.reward])
      elsif !exists?(transaction_id: tx)
        create!(transaction_id: tx, wall_user_id: user_id, reward: amount, last_status: REVERSED)
      end
    end
  rescue ActiveRecord::RecordNotUnique
    nil
  end
end
  • The insert is the gate. A second copy hits the unique index and the transaction rolls back before any coins move. With eight threads sending the same credit at once on PostgreSQL, the balance went up once.
  • The rescue sits outside the block. RecordNotUnique is a kind of ActiveRecord::StatementInvalid, and the Active Record transactions reference warns against catching that inside a transaction block, because on PostgreSQL it leaves the transaction unusable.
  • The balance changes in SQL. coins = coins + ? runs in the database, so no request overwrites another with a stale value.
  • An unknown user is logged, not retried. Retries cannot create the user, so the answer is 200 and you check the warning by hand.

Call these methods outside your own transactions; in Rails tests the block simply becomes a savepoint. More on the pattern in Prevent duplicate reward credits.

Reversals in Rails

A status 2 postback carries the credit’s transaction ID and the same positive reward. reverse! flips last_status from 1 to 2 with a conditional update_all, which succeeds only once even when two copies race, then takes back the stored amount from the user it was credited to. A reversal you never credited is recorded with status 2, so a late credit for it pays nothing.

If the user already spent the coins, the balance goes below zero. The Credits and reversals docs suggest allowing that or holding withdrawals until it recovers, and saying so in your terms. Your own side is in Publisher earnings, settlement, and reversals.

Sharklio waits 6 seconds, so send emails or pushes after credit! returns true, with deliver_later or a job, never deliver_now inside the request.

Status codes and retries

SituationRails answerWhat happens next
Credit or reversal appliedhead :okDone
Duplicate, unmatched reversal, unknown user, test_ transactionhead :okDone, nothing changed twice
Hash wrong, value missing or not a stringhead :forbiddenCounted as failed and retried
Database down or any exception500Retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours
http URL with force_ssl, or a sign-in filter301 or 302Fails; Sharklio does not follow redirects
No answer within 6 secondsTimeoutRetried; the unique index covers work already done

The Rails configuration guide says config.force_ssl forces all requests to be served over HTTPS, through the ActionDispatch::SSL middleware (checked on October 22, 2026, for Rails 8.1.4). Credit only the signed reward, never {payout} or another unsigned macro. If a final retry fails, see How to resend a failed postback.

Step 5: test it before you go live

This test passed on PostgreSQL and SQLite. Set the three SHARKLIO_ variables in your test environment first:

# test/integration/sharklio_postback_test.rb
require "test_helper"

class SharklioPostbackTest < ActionDispatch::IntegrationTest
  setup { @user = User.create!(coins: 0) }

  def postback(tx, status, reward: "315")
    user = @user.id.to_s
    sig = Sharklio.sign(Sharklio::POSTBACK_KEY, "#{tx}:#{user}:#{reward}:#{status}")
    get "/sharklio/postback", params: { tx: tx, user: user, reward: reward, status: status, hash: sig }
  end

  test "credits once and reverses once" do
    2.times { postback("tx_1", "1"); assert_response :ok }
    assert_equal 315, @user.reload.coins
    2.times { postback("tx_1", "2"); assert_response :ok }
    assert_equal 0, @user.reload.coins
  end

  test "rejects a bad hash" do
    get "/sharklio/postback", params: { tx: "tx_2", user: @user.id, reward: "315", status: "1", hash: "0" * 64 }
    assert_response :forbidden
    assert_equal 0, @user.reload.coins
  end
end
  1. Sharklio only calls public servers on ports 80, 443, 8080 or 8443, so bin/rails server on port 3000 cannot receive real postbacks. Deploy behind a proxy first.
  2. In Integration status, compare the Test a link output with Sharklio.wall_url for the same user ID in bin/rails console. They must be identical.
  3. Use Send a test postback: a 200 means the URL, routing and signature are right, and the test_ ID credits no one. Every attempt is listed under Reports, Postbacks.

The full checklist is in How to test your app before it goes live.

Rails mistakes that break postbacks

Comparing hashes with ==. Calling to_d or to_f on the reward before hashing. Rescuing RecordNotUnique inside the transaction block. Inheriting a sign-in filter. Entering the http URL while production forces SSL.

Where this fits on Sharklio

Sharklio is a pay-per-result affiliate network. On a Rails site the wall is a link or iframe your server signs, like Sharklio.wall_url above, and every credit and reversal arrives as a signed server-to-server postback. Reports and the Reporting API show clicks, conversions and reversals by country, offer and sub ID, and there is an Offers API plus mobile SDKs if you need them. 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 Ruby?

Compute OpenSSL::HMAC.hexdigest("SHA256", key, message) over the exact values you received, then compare it with the received hash using ActiveSupport::SecurityUtils.secure_compare.

Do I need skip_forgery_protection for a Rails webhook endpoint?

Not for a GET postback, because Rails never checks CSRF tokens on GET or HEAD requests. A POST webhook on ActionController::Base would need it, while a controller that inherits from ActionController::API has no forgery protection to skip.

How do I stop a Rails postback from crediting twice?

Add a unique index on the transaction ID, insert the row and update the balance in one transaction, and rescue ActiveRecord::RecordNotUnique outside the block. A duplicate then rolls back before any coins move.

Why does my Rails postback return a 301?

Usually config.force_ssl is redirecting an http URL, so enter the https address. A 302 often means a sign-in filter is redirecting to your login page.

Can I use params instead of request.query_parameters?

It works, but params also merges route and body values. Reading request.query_parameters once and accepting only strings keeps the signed and credited values identical.