Skip to main content

PropellerAds S2S Postback Guide: Fixing Missing ${SUBID} and Token Mismatches

 There is no costlier error in performance marketing than a disconnected feedback loop. You spend thousands on traffic, your affiliate network records conversions, but your traffic source—PropellerAds—shows zero conversions.

When the traffic source is blind to conversions, its optimization algorithms cannot function. You are essentially bidding manually against competitors using automated machine learning.

The root cause is almost always a failure in the Server-to-Server (S2S) postback chain. Specifically, the mismanagement of the click ID generation (macros) or the failure to persist that ID through the user session. This guide details exactly how to capture the PropellerAds ${SUBID}, preserve it across your backend architecture, and fire a valid postback using modern PHP (8.2+) and Node.js (v20+).

The Anatomy of an S2S Failure

To fix the tracking, you must understand the lifecycle of the click ID.

PropellerAds uses the macro ${SUBID}. This is not a variable you define; it is a placeholder. When a user sees your ad, PropellerAds replaces ${SUBID} with a unique alphanumeric string (e.g., y893rn9238fn2039f). This string is the primary key for that specific impression/click event.

The failure usually occurs at one of three critical junctures:

  1. Ingestion: The developer uses the wrong parameter name in the campaign URL (e.g., expecting clickid but receiving visitor_id).
  2. Persistence: The user navigates from the landing page to a checkout page, and the ID is lost because it wasn't stored in a session, cookie, or database.
  3. Execution: The postback fires back to PropellerAds sending the literal string ${SUBID} instead of the actual alphanumeric value.

Phase 1: Correct Campaign Configuration

Before writing code, ensure your campaign URL in the PropellerAds dashboard passes the generated ID to your backend.

Incorrect: https://your-landing-page.com/?id={clickid}

Correct: https://your-landing-page.com/?click_id=${SUBID}

By using ${SUBID}, PropellerAds injects the unique token. We map this to a query parameter named click_id (or visitor_id), which your backend will look for.

Phase 2: Ingestion and Persistence (PHP 8.2+)

In a PHP environment, reliance on raw $_GET without sanitization is a security risk, and failing to start the session immediately results in data loss.

We will create a strict utility class to handle the ingestion of the click ID and store it in a secure, HTTP-only cookie. This ensures the ID survives if the user browses multiple pages before converting.

<?php
declare(strict_types=1);

namespace App\Tracking;

class ClickTracker
{
    private const COOKIE_NAME = 'propeller_click_id';
    private const COOKIE_DURATION = 86400 * 30; // 30 Days

    /**
     * Captures the click_id from URL and persists it.
     * Call this at the very top of your entry point (index.php).
     */
    public static function capture(): void
    {
        // 1. Check if the parameter exists in the query string
        $clickId = filter_input(INPUT_GET, 'click_id', FILTER_SANITIZE_SPECIAL_CHARS);

        if ($clickId && self::isValidClickId($clickId)) {
            // 2. Store in an HTTP-only, Secure cookie to prevent XSS theft
            setcookie(
                self::COOKIE_NAME,
                $clickId,
                [
                    'expires' => time() + self::COOKIE_DURATION,
                    'path' => '/',
                    'domain' => $_SERVER['HTTP_HOST'],
                    'secure' => true,     // HTTPS only
                    'httponly' => true,   // Not accessible via JS
                    'samesite' => 'Lax'
                ]
            );
        }
    }

    /**
     * Retrieves the stored click ID for the postback.
     */
    public static function getStoredId(): ?string
    {
        // Prioritize URL param (immediate conversion), fallback to cookie
        $fromUrl = filter_input(INPUT_GET, 'click_id', FILTER_SANITIZE_SPECIAL_CHARS);
        
        if ($fromUrl) {
            return $fromUrl;
        }

        return $_COOKIE[self::COOKIE_NAME] ?? null;
    }

    /**
     * Basic validation to ensure we don't process garbage data.
     */
    private static function isValidClickId(string $id): bool
    {
        // PropellerAds IDs are alphanumeric; adjust regex if format changes
        return preg_match('/^[a-zA-Z0-9\-_]+$/', $id) === 1;
    }
}

Phase 3: The Postback Execution (PHP)

When the conversion occurs (e.g., a "Thank You" page load or a webhook from a payment processor), you must fire the postback.

Use Guzzle (or the native PHP HTTP client) rather than raw cURL for better error handling and connection pooling. This example assumes you are using Composer.

<?php
declare(strict_types=1);

require 'vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use App\Tracking\ClickTracker;

function firePropellerPostback(float $payout = 0.0): bool
{
    // 1. Retrieve the ID we stored earlier
    $clickId = ClickTracker::getStoredId();

    if (!$clickId) {
        error_log("Conversion failed: No PropellerAds click_id found in session.");
        return false;
    }

    // 2. Configure the Postback URL
    // NOTE: Replace 12345 with your actual PropellerAds specific parameter if strictly required,
    // though usually `visitor_id` is the dynamic key.
    $baseUrl = 'http://ad.propellerads.com/conversion.php';
    
    $client = new Client(['timeout' => 5.0]);

    try {
        // 3. Fire the request
        // 'visitor_id' is the standard parameter expected by PropellerAds S2S
        $response = $client->get($baseUrl, [
            'query' => [
                'aid' => 'YOUR_ADVERTISER_ID', // Optional/Context dependent
                'pid' => 'YOUR_PRODUCT_ID',    // Optional/Context dependent
                'visitor_id' => $clickId,      // CRITICAL: The ID captured from ${SUBID}
                'payout' => $payout            // Optional: Pass revenue value
            ]
        ]);

        if ($response->getStatusCode() === 200) {
            return true;
        }
        
        error_log("PropellerAds S2S Error: " . $response->getBody()->getContents());
        return false;

    } catch (GuzzleException $e) {
        error_log("PropellerAds S2S Exception: " . $e->getMessage());
        return false;
    }
}

Phase 4: Implementation in Node.js (Express)

For Node.js environments, we typically use middleware to capture the ID and fetch (native in Node 18+) to trigger the postback.

Middleware for Ingestion

// middleware/tracking.js

/**
 * Middleware to capture click_id and store in signed cookies.
 * Requires 'cookie-parser' middleware to be initialized first.
 */
export const captureClickId = (req, res, next) => {
  const clickId = req.query.click_id;

  // Basic alphanumeric validation
  const isValid = clickId && /^[a-zA-Z0-9\-_]+$/.test(clickId);

  if (isValid) {
    res.cookie('propeller_click_id', clickId, {
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
    });
  }

  next();
};

The Conversion Service

This service handles the actual S2S call. It is crucial to handle promise rejection to prevent unhandled exceptions from crashing your Node process.

// services/postback.js

const PROPELLER_ENDPOINT = 'http://ad.propellerads.com/conversion.php';

/**
 * Fires the conversion postback to PropellerAds
 * @param {string} clickId - The visitor_id captured from the landing page
 * @param {number} payout - Revenue amount (optional)
 */
export const sendPostback = async (clickId, payout = 0) => {
  if (!clickId) {
    console.error('[S2S] Conversion failed: Missing clickId');
    return false;
  }

  const params = new URLSearchParams({
    visitor_id: clickId,
    payout: payout.toString(),
    // aid: 'YOUR_AID', // Uncomment if required by your account manager
    // pid: 'YOUR_PID'
  });

  const url = `${PROPELLER_ENDPOINT}?${params.toString()}`;

  try {
    const response = await fetch(url, {
      method: 'GET',
      headers: {
        'User-Agent': 'NodeJS-Postback-Service/1.0',
      },
    });

    if (!response.ok) {
      const errorText = await response.text();
      console.error(`[S2S] API Error ${response.status}: ${errorText}`);
      return false;
    }

    console.log(`[S2S] Conversion recorded for ID: ${clickId}`);
    return true;

  } catch (error) {
    console.error(`[S2S] Network Error: ${error.message}`);
    return false;
  }
};

Deep Dive: Handling Cache and Edge Cases

Even with correct code, infrastructure can break your tracking.

1. The Caching Problem

If you use Cloudflare or Varnish, your landing page might be cached. If User A hits your site with ?click_id=123 and the page is cached, User B might be served the cached version of the page including the links or hardcoded scripts meant for User A.

The Fix: Ensure your landing page HTML is never cached if you are rendering the click_id directly into the DOM (e.g., hidden form fields). Alternatively, use JavaScript to read the URL params on the client side and inject them into forms dynamically. Client-side JS runs after the cached HTML loads.

2. Double Encoding

Sometimes, when passing parameters through multiple redirects (Tracker -> Network -> Publisher), the ID gets URL-encoded twice (e.g., %257BSUBID%257D).

The Fix: In your ingestion logic, inspect the incoming click_id. If it contains %, perform a urldecode (PHP) or decodeURIComponent (JS) before validation. However, PropellerAds IDs generally do not contain special characters requiring encoding.

3. HTTP vs HTTPS

PropellerAds' postback domain (ad.propellerads.com) historically used HTTP. While they support HTTPS, certificate mismatches on older tracking domains can silently fail.

The Fix: Ensure your server allows outbound traffic to port 80 (HTTP) if you use the standard URL, or verify the SSL certificate validity if forcing HTTPS. The examples above use the standard endpoints provided in the documentation to ensure maximum compatibility.

Conclusion

S2S tracking is deterministic. If the visitor_id generated by PropellerAds at the moment of the click matches the visitor_id received by their server at the moment of conversion, the conversion counts.

If you are seeing discrepancies, audit your flow in this order:

  1. Macro: Is the campaign URL using ${SUBID}?
  2. Ingestion: Is your landing page click_id variable populated?
  3. Storage: Are cookies explicitly set with SameSite=Lax or None?
  4. Postback: Is the backend sending visitor_id (not tid or subid) to the conversion endpoint?

By implementing the strict typing and secure storage methods outlined above, you eliminate the ambiguity that causes 90% of tracking errors.