Key takeaways
- Turn verify_jwt off only for the postback function, check the HMAC of tx:user:reward:status with Web Crypto, and compare equal-length bytes with timingSafeEqual so a forged hash gets a 403.
- Do all crediting in one Postgres function called with rpc(): lock the transaction ID, write a ledger row with a unique transaction ID and status, and change the wallet in the same transaction.
- Enable RLS with select-only policies, revoke writes from anon and authenticated, grant the crediting function to service_role only, and sign the offerwall link in a second function that reads the user ID from the verified JWT.
How do you receive an offerwall postback with Supabase Edge Functions?
Deploy one Edge Function with JWT verification turned off, have it recompute the HMAC-SHA256 of tx:user:reward:status with Web Crypto, compare it with the hash in the URL in constant time, and then call one Postgres function through rpc() that writes a ledger row and changes the balance in the same transaction. A unique key on the transaction ID and status, plus a lock per transaction, makes every credit and every reversal apply exactly once. Row Level Security lets users read their own balance, and nobody can write it from the client.
This guide builds that in four files: a SQL migration, a shared TypeScript module, the postback function, and a function that signs the offerwall link. Every piece was run on Deno 2 against an embedded PostgreSQL before it went on this page. If HMAC or idempotency is new to you, read What is HMAC? and How to prevent duplicate reward credits first. On Firebase instead? See Offerwall postback with Firebase.
Who does what
| Part | Does | Never does |
|---|---|---|
| Your app (browser or mobile) | Asks the offerwall-link function for a signed link and opens it | Holds the link hash salt or postback secret key, writes to wallets |
offerwall-link function | Reads the user ID from the verified JWT, signs the link | Takes a user ID from the request body |
sharklio-postback function | Checks the hash, calls the database function with the secret key client | Changes balances itself, or trusts an unsigned value |
| Postgres function | Locks the transaction, writes the ledger, updates the wallet, all or nothing | Runs for anon or authenticated callers |
| Sharklio | Sends a signed GET for every credit and every reversal | Pays your users directly |
You need three values from your app’s Integration tab: the app ID, 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.
Step 1: wallet, ledger and the crediting function
Create a migration with supabase migration new sharklio_rewards and paste the SQL below. The user_id you put in the offerwall link is the Supabase Auth user ID (auth.users.id), a UUID that never changes. The Sharklio docs accept hyphens in user_id, so it goes into the link as is.
create table public.wallets (
user_id uuid primary key references auth.users (id) on delete cascade,
coins numeric not null default 0,
updated_at timestamptz not null default now()
);
create table public.coin_ledger (
id bigint generated always as identity primary key,
user_id uuid not null,
transaction_id text not null,
status smallint not null check (status in (1, 2)),
amount numeric not null,
created_at timestamptz not null default now(),
unique (transaction_id, status)
);
alter table public.wallets enable row level security;
alter table public.coin_ledger enable row level security;
create policy "Users read their own wallet" on public.wallets
for select to authenticated using ((select auth.uid()) = user_id);
create policy "Users read their own ledger" on public.coin_ledger
for select to authenticated using ((select auth.uid()) = user_id);
revoke insert, update, delete on public.wallets, public.coin_ledger from anon, authenticated;
create function public.create_wallet()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.wallets (user_id) values (new.id);
return new;
end;
$$;
create trigger on_auth_user_created_wallet
after insert on auth.users
for each row execute procedure public.create_wallet();
insert into public.wallets (user_id)
select id from auth.users
on conflict (user_id) do nothing;
create function public.sharklio_apply_event(
p_tx text, p_user uuid, p_reward numeric, p_status smallint
)
returns text
language plpgsql
set search_path = ''
as $$
declare
v_credit public.coin_ledger%rowtype;
begin
perform pg_advisory_xact_lock(hashtextextended(p_tx, 0));
if p_status = 1 then
if exists (select 1 from public.coin_ledger where transaction_id = p_tx) then
return 'duplicate';
end if;
update public.wallets
set coins = coins + p_reward, updated_at = now()
where user_id = p_user;
if not found then
return 'unknown_user';
end if;
insert into public.coin_ledger (user_id, transaction_id, status, amount)
values (p_user, p_tx, 1, p_reward);
return 'credited';
end if;
if p_status = 2 then
if exists (select 1 from public.coin_ledger where transaction_id = p_tx and status = 2) then
return 'duplicate';
end if;
select * into v_credit from public.coin_ledger
where transaction_id = p_tx and status = 1;
if not found then
insert into public.coin_ledger (user_id, transaction_id, status, amount)
values (p_user, p_tx, 2, 0);
return 'not_credited';
end if;
update public.wallets
set coins = coins - v_credit.amount, updated_at = now()
where user_id = v_credit.user_id;
insert into public.coin_ledger (user_id, transaction_id, status, amount)
values (v_credit.user_id, p_tx, 2, -v_credit.amount);
return 'reversed';
end if;
raise exception 'unknown status %', p_status;
end;
$$;
revoke execute on function public.sharklio_apply_event(text, uuid, numeric, smallint)
from public, anon, authenticated;
grant execute on function public.sharklio_apply_event(text, uuid, numeric, smallint)
to service_role;
Why the SQL is written this way
- One transaction for everything. The PostgREST docs say every request to an API resource runs inside a transaction, so one
rpc()call is one transaction. If the ledger insert fails, the balance change rolls back with it. Two separate calls from TypeScript could leave a balance changed with no ledger row. - Leave the function volatile. PostgREST runs a
POSTto aVOLATILEfunction as read-write, andrpc()sendsPOSTby default. Mark itSTABLE, or call it withget: true, and the writes fail as read-only. - The lock does the idempotency.
pg_advisory_xact_locktakes an exclusive lock on a number derived from the transaction ID, waits if another call holds it, and releases it at commit, as the PostgreSQL docs describe. Two copies of one postback run one after the other, and the second returnsduplicate. The unique constraint is the safety net if another writer is added later. - Reversals use the stored credit. Status 2 takes back the amount saved with the credit, from the user it went to. A reversal for a transaction you never credited writes a zero row, so a late copy of that credit, for example one resent by hand, cannot pay afterwards.
- Who may run it. Supabase’s database functions guide warns that by default any role can run a database function, including
anon. Therevokeandgrantat the end leave onlyservice_role, which is the role your secret key uses. - Security invoker on purpose. Supabase recommends
security invoker, the default. The function runs asservice_role, which bypasses RLS, so it does not needsecurity definer. Only the sign-up trigger is a definer, following Supabase’s own user-data example, withsearch_path = ''and schema-qualified names.
Supabase’s user-data guide warns that a failing trigger on auth.users can block sign-ups, so keep create_wallet simple and test a sign-up after the migration.
Step 2: RLS so clients cannot write balances
The migration already enables Row Level Security on both tables and adds one select policy each. There is no insert, update or delete policy, and the revoke removes those privileges from anon and authenticated as a second layer. Supabase’s RLS guide says that once RLS is enabled, no data is accessible through the API with a publishable key until you create policies, and that a secret key uses the service_role role, which has bypassrls.
In a test run, a signed-in user saw only their own wallet, and updating wallets, inserting into coin_ledger, or calling sharklio_apply_event as authenticated or anon all returned permission denied. Your app reads the balance like any other table:
const { data } = await supabase.from('wallets').select('coins').single()
Do not do this
Do not add an update policy “so the app can show optimistic balances”. Do not credit from anything the client reports. Do not put the secret key, the postback key or the link salt in your front end or app bundle. And do not credit {payout} or any other macro that is not part of the hash: only {reward} is signed.
Step 3: the shared module with Web Crypto
Supabase’s development tips suggest a folder that starts with an underscore for code shared between functions. Put this in supabase/functions/_shared/sharklio.ts:
import { timingSafeEqual } from 'jsr:@std/crypto@1/timing-safe-equal'
const encoder = new TextEncoder()
export function env(name: string): string {
const value = Deno.env.get(name)
if (!value) throw new Error(`${name} is not set`)
return value
}
export async function hmacHex(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
return Array.from(new Uint8Array(signature), (b) => b.toString(16).padStart(2, '0')).join('')
}
export function sameHash(given: string, expected: string): boolean {
const a = encoder.encode(given)
const b = encoder.encode(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
export async function wallUrl(userId: string): Promise<string> {
const hash = await hmacHex(env('SHARKLIO_LINK_SALT'), userId)
return `https://wall.sharklio.com/${env('SHARKLIO_APP_ID')}?user_id=${encodeURIComponent(userId)}&hash=${hash}`
}
- Web Crypto, not Node crypto.
crypto.subtle.importKeyandcrypto.subtle.signwith{ name: 'HMAC', hash: 'SHA-256' }are built into Deno, so you need no npm package for the hash. MDN’s SubtleCrypto.sign() page describes the call. The result is bytes, turned into lowercase hex because that is the format Sharklio sends. - Constant-time compare with a length check.
timingSafeEqualfrom the Deno standard library compares bytes without stopping at the first difference. In testing, version 1.1.0 threwCannot compare buffers of different byte lengthswhen a forged 8-character hash came in, which turned a forged request into a 500 instead of a 403. The length check insameHashmakes it a clean 403. - Fail closed on missing secrets.
env()throws when a secret is not set, so the function answers 500 and Sharklio retries later, instead of hashing with an empty key.
Step 4: the postback function with JWT verification off
Sharklio cannot send a Supabase user token, so this function must accept calls without one. Supabase’s Securing Edge Functions guide covers this case under external webhooks: use auth: 'none' in the withSupabase wrapper from @supabase/server, turn verify_jwt off, and verify the provider’s signature inside the handler. Create supabase/functions/sharklio-postback/index.ts:
import { withSupabase } from 'npm:@supabase/server@1'
import { env, hmacHex, sameHash } from '../_shared/sharklio.ts'
const KEYS = ['tx', 'user', 'reward', 'status', 'hash'] as const
type Params = Record<(typeof KEYS)[number], string>
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function readParams(sp: URLSearchParams): Params | null {
const p = {} as Params
for (const key of KEYS) {
const values = sp.getAll(key)
if (values.length !== 1) return null
p[key] = values[0]
}
return p
}
const reply = (status: number) => new Response(null, { status })
export default {
fetch: withSupabase({ auth: 'none', cors: 'disabled' }, async (req, ctx) => {
if (req.method !== 'GET') return reply(405)
const p = readParams(new URL(req.url).searchParams)
if (!p) return reply(403)
let key: string
try {
key = env('SHARKLIO_POSTBACK_KEY')
} catch (err) {
console.error(err)
return reply(500)
}
const expected = await hmacHex(key, `${p.tx}:${p.user}:${p.reward}:${p.status}`)
if (!sameHash(p.hash, expected)) return reply(403)
if (p.tx.startsWith('test_')) {
console.log('sharklio test postback', p.tx, p.status, p.user)
return reply(200)
}
if ((p.status !== '1' && p.status !== '2') || !UUID.test(p.user)) {
console.warn('sharklio postback ignored', p.tx, p.status, p.user)
return reply(200)
}
const { data, error } = await ctx.supabaseAdmin.rpc('sharklio_apply_event', {
p_tx: p.tx,
p_user: p.user,
p_reward: p.reward,
p_status: Number(p.status),
})
if (error) {
console.error('sharklio postback failed', p.tx, error.message)
return reply(500)
}
console.log('sharklio postback', p.tx, p.status, data)
return reply(200)
}),
}
Then turn off the platform JWT check for this one function in supabase/config.toml, as Supabase’s function configuration page shows:
[functions.sharklio-postback]
verify_jwt = false
The CLI flag --no-verify-jwt on supabase functions deploy does the same for one deploy, but the setting in config.toml survives the next deploy by a teammate or CI, so prefer it. Leave verify_jwt on for every other function.
What the handler does, in order
- Rejects anything but GET with 405.
- Requires exactly one value per parameter, so
?tx=a&tx=bis a 403. - Hashes the four values exactly as they arrived.
- Logs test postbacks (their transaction ID starts with
test_) and credits nobody. - Skips, with a 200 and a warning, a signed postback whose user ID is not a UUID: a retry would not fix it.
- Passes the reward as a string, so it reaches a
numericcolumn without floating point. In testing,0.75credited exactly0.75. - Answers 500 when the database call fails, so the event is retried, and 200 for everything applied or skipped.
ctx.supabaseAdmin is built from your secret key and bypasses RLS, which is why the crediting rules live in the database function, not in this file.
Step 5: build the signed offerwall link server-side
The offerwall link must carry a hash of the user ID made with your link hash salt, so it has to be built on a server. A second function does it for the signed-in user. It keeps verify_jwt on and uses auth: 'user', so the user ID comes from the verified token, never from the request.
import { withSupabase } from 'npm:@supabase/server@1'
import { wallUrl } from '../_shared/sharklio.ts'
export default {
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
const url = await wallUrl(ctx.userClaims!.id)
return Response.json({ url }, { headers: { 'Cache-Control': 'private, no-store' } })
}),
}
In testing, the link this function returned for a test user matched, character for character, the one built with PHP’s hash_hmac from the Sharklio docs. The wrapper also answered the browser’s preflight and added the standard supabase-js CORS headers, and a call with no token got a 401. From your app, call it with functions.invoke, which sends the user’s session token for you:
const { data, error } = await supabase.functions.invoke('offerwall-link')
if (!error) document.querySelector('#offerwall').src = data.url
Point an iframe with style="border:0;width:100%;height:100vh" at that URL, as the Offerwall link docs show, or pass it to a WebView or the system browser in a mobile app. The iframe only loads on https pages of the website you registered for the app and its subdomains. To open the wall in a pop-up instead, see Add a floating Earn button to any website. Inside a mobile WebView, read How to open an offerwall in a WebView.
Supabase suggests few, larger functions, but these two need opposite verify_jwt settings, so they stay separate.
Step 6: secrets, deploy, postback URL
Set the three Sharklio values as Edge Function secrets. Locally, Supabase reads them from supabase/functions/.env, which you must add to .gitignore. For your hosted project, use the CLI, as Supabase’s secrets guide describes:
supabase secrets set SHARKLIO_APP_ID=your_app_id
supabase secrets set SHARKLIO_LINK_SALT=your_link_hash_salt
supabase secrets set SHARKLIO_POSTBACK_KEY=your_postback_secret_key
supabase db push
supabase functions deploy sharklio-postback
supabase functions deploy offerwall-link
The guide says functions read a new secret right away, without a redeploy, which makes key rotation quick (see How to rotate your keys and link salt). SUPABASE_URL and SUPABASE_SECRET_KEYS are provided to every function by default, and the wrapper reads them itself; it uses the secret key named default, which every project starts with (checked on October 22, 2026).
Then add the postback URL in your Sharklio dashboard under Apps, your app, Postback, with your project ref:
https://<project-ref>.supabase.co/functions/v1/sharklio-postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
You can add {event_id}, {offer_name}, {sub1} or {integration} for your logs. Only the four signed values decide what you credit.
Credits, reversals and retries
Every event of one result carries the same {transaction_id}, and there are only two statuses. This is what sharklio_apply_event returned for each case in testing:
| Incoming | Already in the ledger | Result | Balance |
|---|---|---|---|
| Status 1 | Nothing | credited | Plus the reward |
| Status 1 | Any row for this transaction | duplicate | Unchanged |
| Status 2 | The credit | reversed | Minus the stored amount |
| Status 2 | Nothing | not_credited (zero row) | Unchanged |
| Status 2 | A reversal | duplicate | Unchanged |
| Status 1 | No wallet for that user | unknown_user | Nothing written |
Amounts are never negative: a reversal carries the same positive {reward} as the credit, and the status tells you to subtract. An offer with several steps sends each step as its own result with its own transaction ID, so each step is another ledger row. If the user already spent the coins, this schema lets the balance go below zero. You can instead hold withdrawals until it is recovered, as long as your terms allow it; Publisher earnings and reversals covers the business side.
Sharklio waits up to 6 seconds for a 2xx answer and does not follow redirects. On a failure or timeout it retries 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. Keep slow work such as emails out of the function. If the last retry fails too, see How to resend a failed postback.
Step 7: test it before you go live
- In Integration status on the Integration tab, use Check a link and its Test a link tool with a real user’s UUID. The link must match the one
offerwall-linkreturns for that user. - Click Send a test postback. Your function should answer 200, and the Logs tab of the function in the Supabase dashboard should show
sharklio test postback. - Test the real crediting path with a hash you compute yourself. Sample values only; export
SHARKLIO_POSTBACK_KEYin your shell and use a UUID from yourauth.userstable:
Run it twice: the wallet gains 150 once, and the log showsTX=manual_1 U=your-user-uuid R=150 S=1 HASH=$(printf '%s' "$TX:$U:$R:$S" | openssl dgst -sha256 -hmac "$SHARKLIO_POSTBACK_KEY" | sed 's/^.* //') curl -i "https://<project-ref>.supabase.co/functions/v1/sharklio-postback?tx=$TX&user=$U&reward=$R&status=$S&hash=$HASH"credited, thenduplicate. SetS=2, recompute the hash, run it again, and the 150 comes back off. - Change one character of the hash, then send a much shorter hash. Both must return 403, not 500.
- After going live, every attempt and your function’s status code is in Reports, Postbacks. See Testing and troubleshooting and How to test your app before it goes live.
What the test run covered
The two functions ran under deno serve (Deno 2.9.8, @supabase/server 1.9.2), with a stand-in for the REST API that ran the migration above on PGlite, an embedded PostgreSQL 17 build. Repeats, parallel copies of one credit, reversals before and after a credit, forged and short hashes, a repeated parameter, a missing secret and a decimal reward all behaved as described. The lock itself was not tested under real concurrency, because PGlite has a single connection.
Reference docs
Sharklio: Postbacks and Credits and reversals. Supabase: Authorization headers, functions deploy, Row Level Security, Database functions and User management. Also the PostgREST transactions reference, PostgreSQL advisory locks and Deno’s timingSafeEqual (all checked on October 22, 2026). The same handler in plain Node.js is in Offerwall postback in Node.js, Express and Next.js, and the threat model behind each check is in Postback security.
One function 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 one Edge Function 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 is reviewed by hand, and once your account is approved, each app you add is live right away. See how the wall works, or get in touch.
Frequently asked questions
How do I receive a webhook in a Supabase Edge Function?
Set verify_jwt = false for that function in supabase/config.toml, because the sender has no Supabase token, and verify the sender’s own signature inside the handler. With @supabase/server, use withSupabase({ auth: 'none' }, ...) and do the signature check yourself.
Why does my Supabase Edge Function return 401 to webhooks?
JWT verification is on by default, so the platform rejects any request without a valid token before your code runs. Turn it off for the webhook function only, and keep it on for functions your signed-in users call.
How do I verify an HMAC signature in a Supabase Edge Function?
Import the key with crypto.subtle.importKey as HMAC SHA-256, sign the message with crypto.subtle.sign, and turn the bytes into lowercase hex. Compare it with the received hash using timingSafeEqual from @std/crypto, after checking that both have the same byte length.
Does supabase.rpc() run in a transaction?
Yes. PostgREST runs each request in one transaction, so everything a Postgres function does in one rpc() call commits or rolls back together. Keep the function volatile, the default, so it runs read-write.
How do I stop users from changing their own balance in Supabase?
Enable Row Level Security on the balance table, add only a select policy for auth.uid(), revoke insert, update and delete from anon and authenticated, and change balances only in a function that only service_role may run.
Which user ID should I put in the offerwall link with Supabase Auth?
Use the user’s auth.users.id UUID. It never changes, it contains only characters the offerwall accepts, and the link function can read it from the verified token instead of trusting the client.