← Back to Guides
RunePort Guide

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.

  1. The player submits their username on the RunePort vote page
  2. RunePort records the vote and builds a signed POST request using your shared secret
  3. RunePort delivers that POST to your callback URL with the signature and timestamp headers
  4. Your handler validates the signature, checks the timestamp, and either grants the reward immediately or queues it
  5. Your handler returns HTTP 200 to confirm receipt
  6. 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.

Write first, respond second. Commit the reward to your database or queue before you return HTTP 200, not after.

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

  1. Callback handler validates the request and writes a pending reward row to your database
  2. Handler returns HTTP 200 immediately after the write
  3. A separate process, cron job, or in-game task loop polls the pending table and attempts fulfilment
  4. On success, the row is marked as fulfilled with a timestamp
  5. 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;
}
Always return HTTP 200 on a detected duplicate. Returning 4xx on a duplicate tells RunePort delivery failed, which may trigger a retry and create more duplicates.

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