Advanced Callback Integration – Security, Queuing, and Edge Cases
Already have a basic callback handler running? This guide covers the next layer: hardening your signature verification, building a reliable reward queue, handling retries, debugging failed deliveries, and avoiding the edge cases that silently lose player rewards.
The basic callback guide gets your endpoint returning HTTP 200 and rewards flowing. This guide picks up from there. It covers the security edge cases that matter in production, how to build a reward queue that does not lose votes when your RSPS is restarting, how to handle duplicate deliveries safely, and how to debug failures that do not have obvious error messages.
None of this is required to get started. All of it is worth implementing before you have a large player base depending on votes working correctly every time.
Understanding the Full Request Lifecycle
Before hardening anything, it helps to understand exactly what happens between a player clicking the vote button and a reward appearing in-game.
- The player submits their username on the RunePort vote page
- RunePort records the vote and builds a signed POST request using your shared secret
- RunePort delivers that POST to your callback URL with the signature and timestamp headers
- Your handler validates the signature, checks the timestamp, and either grants the reward immediately or queues it
- Your handler returns HTTP 200 to confirm receipt
- RunePort logs the result as either sent or failed based on the response code it receives
The part most handlers get wrong is step four and five. Returning 200 should mean you have either granted the reward or safely written it somewhere you will not lose it. Returning 200 and then attempting to grant the reward creates a window where a crash between those two operations silently drops the vote.
Hardening Your Signature Verification
The basic check compares the HMAC and rejects requests that do not match. There are several ways this can quietly fail or be bypassed that are worth closing off.
Always read the raw body before anything else touches it
JSON parsers, middleware layers, and framework request helpers will sometimes normalise whitespace, reorder keys, or strip characters from the body before your code sees it. The HMAC is calculated against the exact bytes RunePort sent. If anything modifies the body before you hash it, the signature will not match even on legitimate requests.
Read the raw input stream directly and store the result as a string. Do all your HMAC work against that string. Only parse the JSON afterwards for the payload fields you actually need.
PHP – raw body with strict header reading
<?php
$secret = 'YOUR_SHARED_SECRET_HERE';
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_RUNEPORT_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_RUNEPORT_TIMESTAMP'] ?? '';
// Reject immediately if either header is missing entirely
if ($sig === '' || $ts === '') {
http_response_code(400);
exit('missing headers');
}
// Freshness check – reject anything older than 5 minutes
$age = abs(time() - strtotime($ts));
if ($age === false || $age > 300) {
http_response_code(408);
exit('expired');
}
// Signature check against the raw body string
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(403);
exit('forbidden');
}
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
http_response_code(400);
exit('bad body');
}
Node.js – Express with raw body middleware
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.RUNEPORT_SECRET;
// Use raw body parser on this route only
app.post('/cb/runeport', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body;
const sig = req.headers['x-runeport-signature'] ?? '';
const ts = req.headers['x-runeport-timestamp'] ?? '';
if (!sig || !ts) return res.status(400).send('missing headers');
const age = Math.abs(Date.now() - new Date(ts).getTime());
if (isNaN(age) || age > 300_000) return res.status(408).send('expired');
const expected = crypto
.createHmac('sha256', SECRET)
.update(rawBody)
.digest('hex');
const sigBuf = Buffer.from(sig, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(403).send('forbidden');
}
const payload = JSON.parse(rawBody.toString());
// hand off to your reward queue here
res.status(200).send('ok');
});
Python – Flask with raw body access
import hmac, hashlib, time
from flask import Flask, request, abort
from datetime import datetime, timezone
app = Flask(__name__)
SECRET = b'YOUR_SHARED_SECRET_HERE'
@app.route('/cb/runeport', methods=['POST'])
def runeport_callback():
raw_body = request.get_data()
sig = request.headers.get('X-RunePort-Signature', '')
ts = request.headers.get('X-RunePort-Timestamp', '')
if not sig or not ts:
abort(400)
try:
sent_at = datetime.fromisoformat(ts.replace('Z', '+00:00'))
age = abs((datetime.now(timezone.utc) - sent_at).total_seconds())
except ValueError:
abort(400)
if age > 300:
abort(408)
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(403)
payload = request.get_json(force=True)
# hand off to your reward queue here
return 'ok', 200
Building a Reward Queue That Does Not Lose Votes
Granting a reward directly inside the callback handler is simple but fragile. If your RSPS server is mid-restart, the game world is unavailable, or the in-game command throws an exception, you have already returned 200 and the vote is gone. A queue decouples delivery from fulfilment.
The pattern
- Callback handler validates the request and writes a pending reward row to your database
- Handler returns HTTP 200 immediately after the write
- A separate process, cron job, or in-game task loop polls the pending table and attempts fulfilment
- On success, the row is marked as fulfilled with a timestamp
- On failure, the row stays pending and the next poll picks it up
Minimum pending rewards table
CREATE TABLE runeport_rewards (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player VARCHAR(40) NOT NULL,
server_id INT UNSIGNED NOT NULL,
event VARCHAR(64) NOT NULL,
issued_at DATETIME NOT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
fulfilled_at DATETIME NULL,
attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
INDEX idx_pending (fulfilled_at, attempts)
);
Inserting from the callback handler (PHP)
$player = trim($_GET['user'] ?? ($payload['player'] ?? ''));
$serverId = (int) ($payload['server_id'] ?? 0);
$event = $payload['event'] ?? '';
$issuedAt = $payload['issued_at'] ?? '';
if (!$player || !preg_match('/^[A-Za-z0-9 _-]{1,40}$/', $player)) {
http_response_code(400);
exit('bad player');
}
$stmt = $pdo->prepare(
'INSERT INTO runeport_rewards (player, server_id, event, issued_at)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([$player, $serverId, $event, $issuedAt]);
http_response_code(200);
echo 'ok';
Handling Duplicate Deliveries Safely
Network conditions occasionally cause a callback to be delivered more than once for the same vote. Your handler needs to detect and discard duplicates without failing, and without double-rewarding the player.
The issued_at timestamp combined with the player name and server ID gives you a natural idempotency key. Before inserting, check whether a row already exists for that combination.
$stmt = $pdo->prepare(
'SELECT id FROM runeport_rewards
WHERE player = ? AND server_id = ? AND issued_at = ?
LIMIT 1'
);
$stmt->execute([$player, $serverId, $issuedAt]);
if ($stmt->fetch()) {
// Already recorded – return 200 so RunePort does not keep retrying
http_response_code(200);
echo 'duplicate';
exit;
}
Replay Attack Prevention in Depth
The five-minute timestamp window catches most replay attempts. There are a couple of additional layers worth adding in a production environment.
Track seen timestamps per request
Two legitimate requests can arrive within

