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:
| Topic | Rails 8 default | What to do for the postback |
|---|---|---|
| CSRF | ActionController::Base checks tokens on every request except GET and HEAD | Nothing for GET; subclass ActionController::API anyway |
| Browser filter | New apps add allow_browser versions: :modern to ApplicationController | Not inherited by an API controller |
| Sign-in | The authentication generator adds before_action :require_authentication | Not inherited; with Base you would need allow_unauthenticated_access |
| HTTPS | config.force_ssl = true in production | Enter the https URL, because an http request gets a redirect |
| Trailing slash | Routes match with or without it | No redirect trap, unlike Django |
| Credit once | A unique index raises ActiveRecord::RecordNotUnique | Rescue 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.fetchfails 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
hexdigestreturns a hex-encoded string, the format the Offerwall link and Postbacks docs expect. Our values matched PHP’shash_hmacexactly. - The comparison does not leak timing. The ActiveSupport::SecurityUtils reference describes
secure_compareas 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 &, 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:
- The query is read once.
request.query_parametersholds 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. - Only plain strings pass. Rails turns
tx[]=1into an array andtx[a]=1into a hash, and a missing key isnil.all?(String)turns all three into a 403 instead of a 500. - Values are hashed exactly as they arrive.
BigDecimal(reward)comes after the check, because315.0would not match315. - 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.
RecordNotUniqueis a kind ofActiveRecord::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
| Situation | Rails answer | What happens next |
|---|---|---|
| Credit or reversal applied | head :ok | Done |
Duplicate, unmatched reversal, unknown user, test_ transaction | head :ok | Done, nothing changed twice |
| Hash wrong, value missing or not a string | head :forbidden | Counted as failed and retried |
| Database down or any exception | 500 | Retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours |
http URL with force_ssl, or a sign-in filter | 301 or 302 | Fails; Sharklio does not follow redirects |
| No answer within 6 seconds | Timeout | Retried; 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
- Sharklio only calls public servers on ports 80, 443, 8080 or 8443, so
bin/rails serveron port 3000 cannot receive real postbacks. Deploy behind a proxy first. - In Integration status, compare the Test a link output with
Sharklio.wall_urlfor the same user ID inbin/rails console. They must be identical. - 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.