Skip to content
Open an account Sign in
Postbacks 8 pages

Postbacks

Signature verification

Complete, runnable callback handlers in PHP, Node.js, Python, Go and Ruby.

On this page 4 sections
  1. Three rules that decide whether this works
  2. Complete handlers
  3. Testing your handler by hand
  4. When it always fails

Every postback carries a signature computed from the transaction, the payout and your app secret:

signature = sha256( trans_id + payout_usd + app_secret )

Plain concatenation, no separators, lower-case hex.

Three rules that decide whether this works#

  1. Hash the string exactly as it arrived. payout_usd is six decimal places and it may carry a minus sign. Parse it to a float, round it, or reformat it before hashing and the signature will never match. Read the raw query value, hash it, and only then convert it to a number.
  2. Compare in constant time. hash_equals, timingSafeEqual, hmac.compare_digest, subtle.ConstantTimeCompare, OpenSSL.secure_compare — not ==.
  3. Reject before you do anything else. No database write, no logging of the amount, no partial credit. A 403 and nothing else.

Complete handlers#

Each of these runs as written. The only thing you supply is ADNUVORA_SECRET in the environment.

They read the macro names as parameter keys — payout_usd, currency_amount, signature. If your callback URL uses shorter keys, as the quick start does, change the lookups to match your own URL.

<?php
// Complete Adnuvora callback handler.
// Run with:  ADNUVORA_SECRET=… php -S localhost:8080 callback.php

$secret = getenv('ADNUVORA_SECRET');

$db = new PDO('sqlite:adnuvora.db', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

// (trans_id, status), never trans_id alone: a reversal carries the SAME
// trans_id as the credit it undoes, and a key on trans_id alone would
// dedupe the reversal away and silently keep the coins.
$db->exec('CREATE TABLE IF NOT EXISTS tx (
    trans_id TEXT, status TEXT, amount REAL, PRIMARY KEY (trans_id, status)
)');

$transId = $_GET['trans_id'] ?? '';
$status  = $_GET['status'] ?? '';
$payout  = $_GET['payout_usd'] ?? '';   // the string as sent — never round it

$expected = hash('sha256', $transId . $payout . $secret);

if (! hash_equals($expected, $_GET['signature'] ?? '')) {
    http_response_code(403);
    exit('invalid signature');
}

$amount = (float) ($_GET['currency_amount'] ?? 0);

try {
    // The unique index is the dedupe. Checking with a SELECT first loses the
    // race when two retries land on two workers in the same millisecond.
    $db->prepare('INSERT INTO tx VALUES (?, ?, ?)')->execute([$transId, $status, $amount]);
} catch (PDOException) {
    exit('ok');   // already processed — still a 2xx, or we retry it again
}

if ($amount < 0) {
    debit_user($_GET['user_id'], abs($amount));    // reversal: take it back
} else {
    credit_user($_GET['user_id'], $amount);
}

echo 'ok';   // we need a 2xx

function credit_user(string $userId, float $amount): void
{
    printf("credit %s %.2f\n", $userId, $amount);
}

function debit_user(string $userId, float $amount): void
{
    printf("debit  %s %.2f\n", $userId, $amount);
}
// Complete Adnuvora callback handler. Zero dependencies.
// Run with:  ADNUVORA_SECRET=… node callback.mjs

import { createServer } from 'node:http';
import { createHash, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.ADNUVORA_SECRET;

// In production this is a UNIQUE index on (trans_id, status), and you catch
// the constraint violation. A Set is enough to see the shape.
//
// The status is part of the key, never trans_id alone: a reversal carries the
// SAME trans_id as the credit it undoes, and a key on trans_id alone would
// dedupe the reversal away and silently keep the coins.
const seen = new Set();

const safeEqual = (a, b) => {
  const x = Buffer.from(a, 'utf8');
  const y = Buffer.from(b, 'utf8');
  return x.length === y.length && timingSafeEqual(x, y);
};

const creditUser = (userId, amount) => console.log('credit', userId, amount.toFixed(2));
const debitUser  = (userId, amount) => console.log('debit ', userId, amount.toFixed(2));

createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');

  if (url.pathname !== '/adnuvora/callback') return void res.writeHead(404).end();

  const q = url.searchParams;
  const transId = q.get('trans_id') ?? '';
  const status  = q.get('status') ?? '';
  const payout  = q.get('payout_usd') ?? '';   // the string as sent — never round it

  const expected = createHash('sha256').update(transId + payout + SECRET).digest('hex');

  if (!safeEqual(expected, q.get('signature') ?? '')) {
    return void res.writeHead(403).end('invalid signature');
  }

  const key = transId + ':' + status;

  if (seen.has(key)) return void res.writeHead(200).end('ok');   // a retry
  seen.add(key);

  const amount = Number(q.get('currency_amount'));

  if (amount < 0) {
    debitUser(q.get('user_id'), Math.abs(amount));   // reversal: take it back
  } else {
    creditUser(q.get('user_id'), amount);
  }

  res.writeHead(200).end('ok');   // we need a 2xx
}).listen(8080);
# Complete Adnuvora callback handler. Standard library only.
# Run with:  ADNUVORA_SECRET=… python callback.py

import hashlib
import hmac
import os
import sqlite3
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse

SECRET = os.environ["ADNUVORA_SECRET"]

db = sqlite3.connect("adnuvora.db", check_same_thread=False)

# (trans_id, status), never trans_id alone: a reversal carries the SAME
# trans_id as the credit it undoes, and a key on trans_id alone would dedupe
# the reversal away and silently keep the coins.
db.execute(
    "CREATE TABLE IF NOT EXISTS tx "
    "(trans_id TEXT, status TEXT, amount REAL, PRIMARY KEY (trans_id, status))"
)


def credit_user(user_id, amount):
    print(f"credit {user_id} {amount:.2f}")


def debit_user(user_id, amount):
    print(f"debit  {user_id} {amount:.2f}")


class Callback(BaseHTTPRequestHandler):
    def do_GET(self):
        query = {k: v[0] for k, v in parse_qs(urlparse(self.path).query).items()}

        trans_id = query.get("trans_id", "")
        status = query.get("status", "")
        payout = query.get("payout_usd", "")  # the string as sent — never round it

        expected = hashlib.sha256((trans_id + payout + SECRET).encode()).hexdigest()

        if not hmac.compare_digest(expected, query.get("signature", "")):
            return self.reply(403, "invalid signature")

        amount = float(query.get("currency_amount", "0"))

        try:
            # The unique index is the dedupe. A SELECT first loses the race.
            db.execute("INSERT INTO tx VALUES (?, ?, ?)", (trans_id, status, amount))
            db.commit()
        except sqlite3.IntegrityError:
            return self.reply(200, "ok")  # already processed

        if amount < 0:
            debit_user(query.get("user_id"), abs(amount))  # reversal
        else:
            credit_user(query.get("user_id"), amount)

        self.reply(200, "ok")  # we need a 2xx

    def reply(self, code, body):
        self.send_response(code)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(body.encode())


HTTPServer(("", 8080), Callback).serve_forever()
// Complete Adnuvora callback handler. Standard library only.
// Run with:  ADNUVORA_SECRET=… go run callback.go

package main

import (
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"log"
	"net/http"
	"os"
	"strconv"
	"sync"
)

var (
	secret = os.Getenv("ADNUVORA_SECRET")

	// In production this is a UNIQUE index on (trans_id, status), and you
	// check the constraint error. A map is enough to see the shape.
	//
	// The status is part of the key, never trans_id alone: a reversal carries
	// the SAME trans_id as the credit it undoes, and a key on trans_id alone
	// would dedupe the reversal away and silently keep the coins.
	mu   sync.Mutex
	seen = map[string]bool{}
)

func creditUser(userID string, amount float64) { log.Printf("credit %s %.2f", userID, amount) }
func debitUser(userID string, amount float64)  { log.Printf("debit  %s %.2f", userID, amount) }

func callback(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	transID := q.Get("trans_id")
	payout := q.Get("payout_usd") // the string as sent — never round it

	sum := sha256.Sum256([]byte(transID + payout + secret))
	expected := hex.EncodeToString(sum[:])

	if subtle.ConstantTimeCompare([]byte(expected), []byte(q.Get("signature"))) != 1 {
		http.Error(w, "invalid signature", http.StatusForbidden)
		return
	}

	key := transID + ":" + q.Get("status")

	mu.Lock()
	duplicate := seen[key]
	seen[key] = true
	mu.Unlock()

	if duplicate {
		w.Write([]byte("ok")) // a retry of one we already handled
		return
	}

	amount, err := strconv.ParseFloat(q.Get("currency_amount"), 64)
	if err != nil {
		http.Error(w, "bad amount", http.StatusBadRequest)
		return
	}

	if amount < 0 {
		debitUser(q.Get("user_id"), -amount) // reversal: take it back
	} else {
		creditUser(q.Get("user_id"), amount)
	}

	w.Write([]byte("ok")) // we need a 2xx
}

func main() {
	http.HandleFunc("/adnuvora/callback", callback)
	log.Fatal(http.ListenAndServe(":8080", nil))
}
# Complete Adnuvora callback handler.
# Run with:  gem install sinatra && ADNUVORA_SECRET=… ruby callback.rb

require "sinatra"
require "digest"
require "openssl"

SECRET = ENV.fetch("ADNUVORA_SECRET")

# In production this is a UNIQUE index on (trans_id, status), and you rescue
# the constraint error. A Hash is enough to see the shape.
#
# The status is part of the key, never trans_id alone: a reversal carries the
# SAME trans_id as the credit it undoes, and a key on trans_id alone would
# dedupe the reversal away and silently keep the coins.
SEEN = {}
LOCK = Mutex.new

def credit_user(user_id, amount)
  puts format("credit %s %.2f", user_id, amount)
end

def debit_user(user_id, amount)
  puts format("debit  %s %.2f", user_id, amount)
end

get "/adnuvora/callback" do
  trans_id = params["trans_id"].to_s
  payout   = params["payout_usd"].to_s   # the string as sent — never round it

  expected = Digest::SHA256.hexdigest(trans_id + payout + SECRET)

  unless OpenSSL.secure_compare(expected, params["signature"].to_s)
    halt 403, "invalid signature"
  end

  key = "#{trans_id}:#{params['status']}"

  duplicate = LOCK.synchronize do
    SEEN.key?(key).tap { SEEN[key] = true }
  end

  halt 200, "ok" if duplicate   # a retry of one we already handled

  amount = params["currency_amount"].to_f

  if amount.negative?
    debit_user(params["user_id"], amount.abs)   # reversal: take it back
  else
    credit_user(params["user_id"], amount)
  end

  "ok"   # we need a 2xx
end

Testing your handler by hand#

Compute a signature for a fake transaction and call your own endpoint:

SECRET='your-app-secret'
TRANS='OFFER19624243-Gw2CpY'

fire () {   # fire <payout_usd> <currency_amount> <status>
  SIG=$(printf '%s' "$TRANS$1$SECRET" | sha256sum | cut -d' ' -f1)
  curl -i "http://localhost:8080/adnuvora/callback\
?user_id=USER_123&trans_id=$TRANS&payout_usd=$1&currency_amount=$2&status=$3&signature=$SIG"
}

fire 0.091000 91.00 credited     # credits 91
fire 0.091000 91.00 credited     # a retry — must credit nothing, must return 200
fire -0.091000 -91.00 reversed   # the reversal — must DEBIT 91

Three calls and you have tested everything that matters: the signature, the dedupe, and the reversal branch.

Note that the third call reuses the same trans_id — that is what a real reversal does. If your dedupe key is trans_id alone, the third call is swallowed as a duplicate and the user keeps coins you were charged back for. Key on (trans_id, status).

Change one character of the signature and confirm you get a 403 and no credit. That is the fourth call, and then you are finished.

When it always fails#

See Troubleshooting. The cause is almost always one of two things: the payout string was reformatted before hashing, or the secret in your environment belongs to a different app.