Documentation

Refreal

A lightweight, self-hosted affiliate tracking engine. Vanilla PHP 8.1+ and SQLite3 — no Composer, no npm, no build step. Drop the folder anywhere on a PHP host — domain root or any subdirectory — and it works.

Tracks referrals across Polar.sh, Stripe, and any custom checkout, with configurable cookie lifetime, global + per-affiliate attribution windows, dual affiliate onboarding (you add them, or they apply themselves), affiliate-managed payout profiles, built-in transactional email, and CSV payout exports. Fully responsive admin panel and affiliate portal — works as well on a phone as a desktop.


1. Requirements

  • PHP 8.1+ with the pdo_sqlite and sqlite3 extensions enabled (both are bundled with PHP by default on most hosts).
  • Any web server (Apache, nginx, or PHP's built-in server for local dev).
  • Writable data/ directory (the app creates the SQLite file and a one-time INITIAL_PASSWORD.txt there on first run).

No database server, no composer install, no npm install.

2. Install

  1. Upload the refreal/ folder anywhere on your server — the domain root, a subdomain (track.yourdomain.com), or a subdirectory (yourdomain.com/tools/refreal/). Every link in the app resolves itself relative to wherever it's actually deployed, so there's nothing to configure.
  2. Make sure data/ is writable by the PHP process: chmod 775 data
  3. Visit admin/login.php under wherever you put it (e.g. https://yourdomain.com/admin/login.php or https://yourdomain.com/tools/refreal/admin/login.php). On first load, Refreal automatically creates the SQLite database from schema.sql and generates: - A random master password (written once to data/INITIAL_PASSWORD.txt) - A random API key for the generic checkout endpoint. The Polar and Stripe webhook secrets are left blank on purpose — see Webhook setup
  4. Open data/INITIAL_PASSWORD.txt, copy the password, log in, then immediately set a new password from Settings → Master Password and delete that file.

Local dev quick start

cd refreal
php -S localhost:8000 router.php

Then visit http://localhost:8000/admin/login. The bundled router.php emulates the .htaccess clean-URL rewrite so /admin/login works the same locally as it will under Apache — see Clean URLs below. (You can omit router.php and it'll still work, just via the .php paths instead of clean ones.)

Upgrading an existing install

Replace the application files (everything except data/) with the new version and reload any page. includes/db.php automatically adds any new columns/settings a newer version introduced — no manual SQL step needed. See Database Schema → Auto-migration.

3. How attribution works

  1. You embed tracker.js once on your marketing site — but see note below first if Refreal is hosted on a different domain than your storefront:

    <script src="https://track.yourdomain.com/tracker.js"
            data-refreal-endpoint="https://track.yourdomain.com"></script>
    

    Note: tracker.php serves the exact same script — it just readfile()s tracker.js and re-sends it with PHP-controlled headers, since some hosts misconfigure static .js MIME types in a way that makes a cross-origin <script> load get silently blocked (CORB). Admin → Settings gives you the tracker.js snippet above by default; if it silently fails to load on your storefront, swap in tracker.php instead — same script, PHP-served. Only include one of the two on a given page, not both.

  2. When someone visits with ?via=THEIRCODE in the URL, the snippet POSTs to click.php, which logs the click and returns a click_uid. The snippet stores it in a refreal_via cookie (lifetime = Cookie Expiry, set in Settings) — on the domain the visitor is actually browsing (your storefront), not Refreal's own domain.

  3. At checkout, that click_uid has to reach your payment provider — how depends on how the checkout itself is triggered:

    • Polar.sh / Stripe, using a plain Checkout Link or Payment Link (a static URL, opened in a new tab or intercepted by the provider's own embed script): tracker.js does this automatically. It finds any polar.sh or buy.stripe.com link on the page and rewrites its href to add ?reference_id= (Polar) or ?client_reference_id= (Stripe) — both are query parameters the provider copies onto the Checkout Session (and from there onto the Order) automatically. Nothing else to build for this path.
    • Custom backend that creates the Checkout Session via the provider's API (rather than linking to a static Checkout Link): your backend reads the refreal_via cookie from the incoming request and sets it as metadata.refreal_click (Polar) or client_reference_id / metadata.refreal_click (Stripe) when it calls the Checkout API.
    • Custom checkout: click_uid field in the POST body to webhooks/generic.php

    A checkout link on a domain tracker.js doesn't recognize can still be tagged automatically — add a data-refreal-param="reference_id" (or whatever query param that provider expects) attribute to the link, or data-refreal-checkout as a shorthand for reference_id.

  4. When the provider webhook fires, Refreal looks up the click, resolves the affiliate, and compares conversion_date - click_date against the Attribution Window (global default, or a per-affiliate override set on the Affiliates page). If it's outside the window, the sale is logged as Expired / Unattributed with $0 commission — still visible for your records, just not payable.

If no click_uid is available (e.g. a custom integration that only has the referral code), you can pass ref_code / metadata.refreal_ref instead — commission will still apply, but with no click timestamp to check, attribution can't expire in that path.

Refreal is often deployed on its own domain or subdomain purely as a tracking backend (e.g. track.yourstore.com), separate from the storefront affiliates actually send traffic to (e.g. yourstore.com). Because of that, the referral link shown to affiliates (<store>/?via=CODE) is built from a dedicated Store / Landing Page URL setting on Settings → General, not from whatever domain Refreal itself happens to be running on. Set this to your real storefront URL once and every affiliate portal page, welcome email, and approval email picks it up automatically. If you leave it blank, Refreal falls back to its own install domain — correct only if you genuinely run Refreal on the same domain as your storefront.

4. Webhook setup

All URLs and secrets are shown live in Admin → Settings → Integrations (they'll use your actual deployed URL automatically, subdirectory and all).

Provider URL Events Auth
Polar.sh webhooks/polar.php order.paid, order.refunded Standard Webhooks signature — paste the secret Polar's dashboard shows you into Settings; Refreal doesn't generate one of its own
Stripe webhooks/stripe.php checkout.session.completed, charge.refunded Stripe signature — paste the whsec_... secret Stripe issues you into Settings; same reasoning as Polar
Custom webhooks/generic.php POST with event: "sale" or "refund" X-Refreal-Api-Key header — the one secret Refreal does generate and let you regenerate, since it's the only party that needs to know it

Generic checkout payload example:

{
  "click_uid": "the-refreal_via-cookie-value",
  "external_id": "order_98213",
  "customer_email": "buyer@example.com",
  "amount_cents": 4900,
  "currency": "USD",
  "event": "sale"
}

5. Clean URLs

Every link the app generates is extension-less by default — /admin/settings instead of /admin/settings.php, /portal?token=... instead of /portal.php?token=..., and so on. This is handled two ways depending on how you're running it:

  • Apache — the bundled .htaccess rewrites clean URLs to their real .php file internally (no visible redirect), and separately redirects anyone who still hits an old .php link to the clean version — except webhooks/*.php and click.php, which are left exactly as configured since those are POST endpoints called by Stripe/Polar/tracker.js, not browser links (redirecting a POST is asking for trouble with some clients). Requires mod_rewrite enabled and AllowOverride All for the app's directory — see Deployment.
  • nginx or the PHP built-in dev server — neither reads .htaccess. For nginx, add the equivalent location/try_files block from Deployment. For local dev, run php -S localhost:8000 router.php — the bundled router.php emulates the same behavior.

Nothing is forced: the old .php paths keep working right alongside the clean ones everywhere (including anything you already have configured in a Stripe/Polar webhook dashboard) — clean URLs are additive, not a breaking migration.

6. Affiliate onboarding

Two ways to add affiliates, both landing in the same table:

  • Manual — add them yourself from Admin → Affiliates → + New Affiliate. Active immediately.
  • Self-serve — share signup.php (e.g. https://yourdomain.com/signup.php). Toggle this on/off, and choose whether new applications need your approval first, from Settings → Affiliate Signup. Approving/rejecting happens right from the Affiliates → Pending tab.

Every affiliate — however they signed up — gets a private, tokenized portal link (no login required):

https://yourdomain.com/portal.php?token=<their-portal-token>

Find/copy/regenerate it from Admin → Affiliates → Edit. From there they can see their referral link, clicks, conversions, running balance, and manage their own payout details (PayPal / Wise / Bank / Manual) without emailing you back and forth.

7. Payouts

Admin → Payouts is a ledger of every conversion. Select pending rows to Approve, then select approved rows to Mark Paid (grouped into a payout batch per affiliate, with method + note — each affiliate gets an automatic email receipt if a mailer is configured). Export Approved or Paid conversions as CSV — the export includes each affiliate's payout destination, ready for a PayPal Mass Pay / Wise batch / bank run.

8. Transactional email

Admin → Settings → Transactional Email — zero external dependencies, two drivers: - PHP Mail (basic) — works out of the box if your host has a local MTA configured, but many hosts block it outright or route through it in a way that gets flagged as spam (Gmail especially). Fine for a quick local test; not what you want to depend on in production. - SMTP (recommended) — a built-in raw SMTP client (STARTTLS/SSL, AUTH LOGIN) that talks to Gmail, SendGrid, Postmark, Mailgun, or any standard relay — no PHPMailer, no Composer. Deliverability is far more reliable since you're authenticating as a real sender the receiving server trusts.

There are exactly four emails the system sends — no others, and nothing recurring/scheduled:

Email Sent to Triggered by Sent when
Welcome / application received
refreal_mail_affiliate_welcome()
The affiliate (their signup email) A self-serve signup at signup.php Always, immediately on submit. Wording adapts automatically: if Settings → Affiliate Signup → Require manual approval is on, it says the application is pending; if off, it includes their referral link right away since they're already active. Not sent for an affiliate you add yourself from Admin → Affiliates → + New Affiliate — that path skips email entirely, on the assumption you're telling them yourself.
New signup alert
refreal_mail_admin_new_signup()
You (the store owner) Same self-serve signup as above Only if Settings → Transactional Email → Email me when a new affiliate signs up is checked and an Admin Notification Email is set — both conditions required, silently skipped otherwise. Only fires for self-serve signups (an affiliate you add manually doesn't need a heads-up).
Application approved
refreal_mail_affiliate_approved()
The affiliate You clicking Approve on a pending affiliate in Admin → Affiliates Only when the affiliate actually has an email on file, and only on that pending→active transition — approving via the "Add Affiliate" form's active-by-default option never fires this, since there was no pending state to approve out of.
Payout sent
refreal_mail_payout_paid()
The affiliate You clicking Mark Paid in Admin → Payouts One email per affiliate per payout batch — if you mark five of one affiliate's conversions paid in the same action, they get a single email with the combined total, not five. Only sent if they have an email on file; a missing/failed send never blocks or rolls back the payout itself.

All four are plain text (no HTML templating, intentionally — keeps this dependency-free and maximizes deliverability) and go out synchronously, inline with the request that triggered them; there's no queue or retry, so a slow or failed send adds to that request's response time but never fails the underlying action (signup, approval, or payout) if the email itself fails.

The store name used inside these emails (the "— Refreal" sign-off, "Welcome to the Refreal affiliate program", etc.) is fixed as "Refreal" in every case — site_name was removed as a configurable setting app-wide a while back, but the mailer templates still read it, so they just always get the "Refreal" fallback. If you've set a Program Name under Settings → General & Attribution (see How attribution works), that only affects the affiliate portal's "<name> sale" wording today, not these email templates — worth asking for as a follow-up if you'd like the emails to say your program's name too.

Send yourself a test email from the Settings page (uses the same driver and templates path, minus the four triggers above) to confirm delivery is working before relying on it.

9. Security notes

  • The data/ directory (holding the SQLite file and the one-time password file) is blocked from public access via .htaccess — if you're on nginx, add: nginx location ~ ^/(data|includes)/ { deny all; }
  • Rotate the generic checkout API key any time from Settings — the old value stops working immediately. Polar and Stripe secrets are entered directly (paste-and-save), matching whatever each provider's dashboard shows you — generate a new one on their end first if you need to rotate those.
  • The admin panel uses PHP sessions + CSRF tokens on every mutating request. Always serve the app over HTTPS in production.
  • The self-serve signup form includes a honeypot field to deter basic bots; pair with your web server's rate limiting if you get spam applications.

10. Directory structure

refreal/
├── config.php            # Bootstraps DB connection, sessions, helpers, mailer
├── .htaccess              # Apache clean-URL rewrite rules (production)
├── router.php              # Clean-URL emulation for `php -S` (local dev only)
├── schema.sql               # SQLite schema + default settings
├── tracker.js                # small client snippet, no dependencies
├── tracker.php                # same script, PHP-served (correct headers on hosts that misconfigure static .js)
├── click.php                  # CORS click logger / cookie writer
├── portal.php                # Token-gated affiliate dashboard + payout profile editor
├── signup.php                 # Public self-serve affiliate application form
├── data/                       # SQLite DB lives here (gitignored, protected)
├── includes/
│   ├── db.php                    # Connection + first-run bootstrap + auto-migration
│   ├── helpers.php                # Settings, base_url()/full_url() routing,
│   │                                formatting, CSRF, CORS, payout-profile helpers
│   ├── mailer.php                  # Zero-dependency mailer (php_mail / SMTP) + templates
│   └── attribution.php               # Shared conversion-recording engine
├── webhooks/
│   ├── polar.php
│   ├── stripe.php
│   └── generic.php
└── admin/
    ├── login.php / logout.php
    ├── index.php               # Overview
    ├── affiliates.php           # Manage affiliates, approve/reject applications
    ├── settings.php               # Global config, signup toggle, mailer, keys
    ├── payouts.php                  # Ledger + CSV export (with payout details)
    └── includes/                      # Shared layout partials (responsive sidebar)

Architecture

Design goals

  • Zero build step. No Composer, no npm, no framework. Every file is either plain PHP that runs as-is on any 8.1+ host, or a static asset.
  • Single tenant. One store owner, one master password, one SQLite file. There's no multi-account or multi-org layer — if you need that, fork it.
  • Provider-agnostic conversions. Polar, Stripe, and "custom" all funnel into one function, refreal_record_conversion(), so adding a fourth provider later is a ~40 line webhook file, not a new subsystem.

Request flow

1. Click capture

Visitor clicks affiliate link (yoursite.com/?via=CODE)
        │
        ▼
tracker.js (runs on page load) reads ?via=, POSTs to click.php
        │
        ▼
click.php validates the referral code against `affiliates`,
inserts a row into `clicks` with a fresh click_uid (UUID),
sets the refreal_via cookie (client-side via tracker.js AND
server-side as a fallback), returns { click_uid, cookie_days }

2. Checkout

Visitor reaches a page with a checkout link/button. tracker.js
(running on that page too) reads the refreal_via cookie and, in
a capture-phase click listener, rewrites the link's href to add
?reference_id= (Polar) or ?client_reference_id= (Stripe) before
the click is followed — or, for a custom backend integration,
your checkout code reads the cookie itself and passes its value
into the provider's metadata field / your own order record.

3. Conversion recording

Provider (Polar / Stripe / your backend) fires a webhook on
successful payment
        │
        ▼
webhooks/{polar,stripe,generic}.php verifies the signature/API key,
extracts amount + click_uid (or ref_code) + external_id,
        │
        ▼
includes/attribution.php: refreal_record_conversion()
  1. Idempotency check on (source, external_id) — skip duplicates
  2. Resolve click_uid → clicks row → affiliates row
     (or ref_code → affiliates row directly, no click timestamp)
  3. If resolved via a click: compare conversion time vs click time
     against the effective attribution window (per-affiliate override,
     else global setting) → status 'pending' or 'expired_unattributed'
  4. Compute commission (percent or fixed) via refreal_calc_commission()
  5. INSERT into `conversions`
        │
        ▼
Row now visible in Admin → Overview / Payouts and the affiliate's
own portal.php page.

4. Payout

Admin approves pending conversions → status 'approved'
Admin selects approved conversions → "Mark Paid"
  → grouped by affiliate_id, one `payouts` row created per affiliate
    with the summed commission_cents
  → conversions updated to status 'paid', payout_id set
Admin exports a CSV of 'approved' or 'paid' conversions for manual
bank/PayPal/Wise transfer.

File map

config.php              Bootstraps constants, session, requires db.php + helpers.php
schema.sql               Table definitions + default settings rows
includes/
  db.php                  refreal_db(): lazy PDO connection, auto-creates
                          schema + secrets on first request that touches it,
                          auto-migrates new columns on existing installs
  helpers.php               Settings cache, base_url()/full_url() routing,
                             money/date formatting, CSRF, CORS, admin auth
                             guards, payout-profile JSON helpers
  mailer.php                  Zero-dependency mailer (php_mail or raw SMTP)
                               + the plain-text email templates
  attribution.php               refreal_record_conversion() / refreal_refund_conversion()
                                 — the one place conversion rows get written
tracker.js                Vanilla JS snippet, no dependencies
tracker.php               Same script, re-served with PHP-controlled headers
                             (fallback for hosts that misconfigure static .js MIME types)
click.php                  Public POST endpoint tracker.js calls
portal.php                   Public GET/POST endpoint, token-gated per affiliate;
                              also handles the affiliate's own payout-profile edits
signup.php                     Public self-serve affiliate application form
                                (gated by Settings → Affiliate Signup)
webhooks/
  polar.php                   Polar.sh webhook (Standard Webhooks signature)
  stripe.php                    Stripe webhook (Stripe-Signature header)
  generic.php                     Custom checkout webhook (API key header)
admin/
  login.php / logout.php          Session-based auth against master_password_hash
  index.php                        Overview dashboard
  affiliates.php                    Affiliate CRUD, pending-approval queue,
                                     payout-profile fields, portal token management
  settings.php                       Global config, signup toggle, mailer config,
                                      secret rotation / manual secret entry
  payouts.php                         Ledger, approve/pay workflow (sends payout
                                       emails), CSV export (includes payout details)
  includes/layout_top.php               Shared <head> + responsive sidebar
                                         (hamburger + slide-over on mobile)
  includes/layout_bottom.php              Shared </body></html>
data/                    SQLite file + one-time password file live here
                         (blocked from public HTTP access)

Subdirectory-safe routing

Every internal link — admin nav, login/logout redirects, the affiliate portal link, webhook URLs shown in Settings, the tracker snippet — is built through helpers in includes/helpers.php, never hardcoded:

base_url('admin/index.php')   // → '/admin/index' at domain root,
                                //   or '/refreal/admin/index' if the
                                //   app is deployed under /refreal/
full_url('portal.php?token=x') // → same, with scheme + host prepended
admin_url('settings.php')      // → shorthand for base_url('admin/...')

base_url() reads dirname($_SERVER['SCRIPT_NAME']) — the folder the currently executing script lives in — and strips a trailing /admin or /webhooks segment so the result always points at the app's root, regardless of whether the request came from a root file, admin/, or webhooks/. This is what makes moving the whole refreal/ folder to a different path (or a subdomain, or a sub-path like example.com/tools/refreal/) work with zero code changes — see Deployment.

base_url() also strips a trailing .php from whatever path it's given (handling a ?query=string suffix correctly, e.g. portal.php?token=abc → portal?token=abc), which is what produces the app's clean URLs — see Clean URLs for the Apache/nginx/dev-server config that makes those extension-less paths actually resolve. Every call site gets this for free with no per-page changes, since they all route through base_url().

Zero-dependency transactional mailer

includes/mailer.php sends every outgoing email (self-serve signup confirmation, admin new-signup notice, approval notice, payout-paid receipt) through one function, refreal_send_mail(), which dispatches to one of two drivers chosen in Settings → Transactional Email:

  • php_mail ("PHP Mail (basic)" in Settings) — PHP's built-in mail(), relies on a local MTA (works out of the box on some Linux hosts, unreliable on Windows/shared hosts without one configured, and increasingly blocked outright by hosting providers or landed in spam by receiving servers, Gmail in particular, since there's no authenticated sending domain behind it). Treat it as a basic fallback, not the option to build on.
  • smtp ("SMTP (recommended)" in Settings) — a ~150 line raw SMTP client over fsockopen() (refreal_send_via_smtp()), supporting STARTTLS/SSL and AUTH LOGIN. No PHPMailer, no Composer — enough to talk to Gmail, SendGrid, Postmark, or any standard SMTP relay, and the one that actually authenticates as a real sender.
  • disabled — no-op.

Templates live at the bottom of includes/mailer.php as small functions (refreal_mail_affiliate_welcome(), refreal_mail_admin_new_signup(), refreal_mail_affiliate_approved(), refreal_mail_payout_paid()) that build a plain-text body and call refreal_send_mail(). Mail failures are always caught and logged via error_log() — a broken mailer never blocks signup, approval, or payout actions from completing.

Dual affiliate onboarding

Affiliates enter the system one of two ways, both converging on the same affiliates table (signup_source records which):

  • Manual — an admin fills out the "New Affiliate" form on Admin → Affiliates. Always created as active immediately.
  • Self-serve — a visitor fills out signup.php (a honeypot field guards against basic bots). Controlled by two settings: Settings → Affiliate Signup → Allow public self-serve signup (a master on/off switch — when off, signup.php shows a "signups are closed" message) and Require manual approval (when on, new signups land as status = 'pending' and don't get a working referral link until an admin approves them from Affiliates → Pending; when off, they're active immediately).

Pending affiliates still get a portal link (portal.php) so they can log in and see their application status, but click.php only accepts clicks for status = 'active' affiliates, so their referral link is inert until approved.

Why SQLite (and its limits)

SQLite is a great fit here: single-writer admin workflows, low write volume (webhooks fire per-sale, not per-request), zero ops. PRAGMA journal_mode = WAL is enabled so reads and writes don't block each other under normal load.

If you're running a very high-volume storefront (hundreds of checkouts a minute) or want horizontal scaling, swap includes/db.php for a PDO MySQL/Postgres connection — the rest of the app talks to the database exclusively through PDO and plain SQL, so the port is mechanical (a few SQLite-specific bits: datetime('now') defaults and INSERT OR IGNORE / ON CONFLICT syntax in schema.sql and helpers.php).

No framework, on purpose

There's no router, no ORM, no templating engine. Every admin/*.php file is a normal PHP script: handle POST actions at the top, query the data you need, require the shared layout, echo HTML with <?= e($x) ?> escaping. This keeps the whole codebase readable top-to-bottom without jumping between abstraction layers — intentional for a project meant to be forked and modified by a single operator, not maintained by a team against a shared framework.

Database Schema

SQLite file: data/refreal.sqlite. Created automatically from schema.sql on first request that touches the database. Foreign keys and WAL journaling are enabled via PRAGMA in includes/db.php.

settings

Key-value store for everything configurable from Admin → Settings.

Column Type Notes
key TEXT (PK) e.g. attribution_window_days
value TEXT Always stored as text; cast on read

Rows seeded by schema.sql, values overwritten on first bootstrap or from the Settings page:

Key Default Meaning
site_name Refreal Fixed — not a configurable setting (no UI field writes this key; branding is hardcoded as "Refreal" everywhere)
cookie_expiry_days 30 Lifetime of the refreal_via browser cookie
attribution_window_days 30 Global lookback window in days
currency USD Default display currency
master_password_hash (generated) password_hash() output, bcrypt
polar_webhook_secret (generated) Standard Webhooks signing secret
stripe_webhook_secret (generated) Stripe signing secret (whsec_... equivalent, self-issued)
generic_api_key (generated) Bearer-style key for webhooks/generic.php
cors_allowed_origins * Comma-separated list, or *
self_serve_signup_enabled 1 Whether signup.php accepts new applications
self_serve_requires_approval 1 If 1, self-serve signups land as pending until an admin approves them
default_commission_type percent Applied to new self-serve signups
default_commission_rate 20 Applied to new self-serve signups
mailer_driver php_mail php_mail | smtp | disabled
mail_from_address (empty) Falls back to no-reply@<host> if unset
mail_from_name Refreal
smtp_host / smtp_port / smtp_username / smtp_password / smtp_encryption — Only used when mailer_driver = smtp
notify_admin_on_signup 1 Email the store owner on every new self-serve application
admin_notification_email (empty) Where that notification goes

affiliates

Column Type Notes
id INTEGER PK
name TEXT Required
email TEXT Nullable; used for CSV payout export and transactional email
referral_code TEXT Unique, auto-generated (refreal_referral_code()), e.g. JOHND93A1
portal_token TEXT Unique, 40-char hex secret for portal.php?token= access
commission_type TEXT percent or fixed
commission_rate REAL Percent value (e.g. 20 = 20%) or flat dollars (e.g. 5 = $5.00) depending on type
attribution_window_days INTEGER, nullable Per-affiliate override; NULL = use global setting
status TEXT pending (awaiting approval), active, or paused
notes TEXT Free text, admin-only (self-serve signups store their pitch/website here)
payout_method TEXT paypal | wise | bank | manual
payout_details TEXT JSON blob, shape depends on payout_method — see below
signup_source TEXT manual (admin-created) or self_serve (came through signup.php)
created_at TEXT ISO datetime, UTC

payout_details JSON shape

Built/read by refreal_build_payout_details() / refreal_payout_details() in includes/helpers.php. Only the fields relevant to the current payout_method are stored, so switching methods doesn't leave stale data behind:

payout_method Keys stored
paypal paypal_email
wise wise_email, wise_account_holder
bank bank_account_name, bank_account_number, bank_routing_number, bank_name, bank_swift
manual manual_notes

Both the admin (Affiliates → Edit) and the affiliate themselves (Portal → Payout Details) can write to this field.

clicks

One row per tracked pageview that carried a valid ?via= code.

Column Type Notes
id INTEGER PK
click_uid TEXT Unique UUID v4, this is the value stored in the refreal_via cookie
affiliate_id INTEGER FK → affiliates ON DELETE CASCADE
landing_url TEXT Page the visitor landed on
referrer TEXT document.referrer at time of click
ip_hash TEXT SHA-256 of IP + a salt — never stores raw IPs
user_agent TEXT Truncated to 512 chars
created_at TEXT ISO datetime, UTC — this is the timestamp attribution windows are measured from

Indexes: affiliate_id, click_uid.

conversions

One row per recorded sale (or attempted-but-unattributed sale).

Column Type Notes
id INTEGER PK
affiliate_id INTEGER FK → affiliates, nullable NULL if never resolved to an affiliate
click_id INTEGER FK → clicks, nullable NULL if attributed by ref_code only, or unattributed
source TEXT polar | stripe | generic
external_id TEXT Provider's unique ID for the transaction — idempotency key
customer_email TEXT Nullable
amount_cents INTEGER Full sale amount, in the smallest currency unit
currency TEXT ISO 4217, e.g. USD
commission_cents INTEGER What's owed to the affiliate, 0 if expired/unattributed
status TEXT See status table below
payout_id INTEGER FK → payouts, nullable Set once grouped into a paid batch
raw_payload TEXT Full JSON of the webhook body, for debugging/audit
created_at TEXT ISO datetime, UTC

Unique index on (source, external_id) — this is what makes webhook retries safe to replay.

Conversion status values

Status Meaning Commission payable?
pending Recorded, within attribution window, awaiting admin approval Yes, once approved
approved Admin has approved it for payout Yes
paid Included in a payout batch Already paid
refunded Provider reported a refund after the fact No
expired_unattributed Matched to a click, but conversion happened after the attribution window closed No (commission forced to 0)
unattributed No matching click or referral code found at all No

payouts

One row per payout batch (created per-affiliate when you "Mark Paid" on the Payouts screen — multiple conversions collapse into one payout row).

Column Type Notes
id INTEGER PK
affiliate_id INTEGER FK → affiliates ON DELETE CASCADE
amount_cents INTEGER Sum of the batch's commission_cents
method TEXT paypal | wise | bank | manual
status TEXT approved or paid (currently always inserted as paid)
note TEXT Optional free text entered at payout time
created_at TEXT ISO datetime, UTC

Auto-migration for existing installs

includes/db.php re-runs schema.sql on every connection (its CREATE TABLE IF NOT EXISTS / INSERT OR IGNORE statements are safe to repeat), then runs refreal_migrate(), which adds any columns a newer version introduced but that CREATE TABLE IF NOT EXISTS can't retrofit onto an existing table — currently affiliates.payout_method, affiliates.payout_details, and affiliates.signup_source. Existing rows get sensible defaults (payout_method = 'paypal', signup_source = 'manual') and no existing data is touched. This means upgrading Refreal in place (replacing the app files, keeping data/) is enough — no manual ALTER TABLE required.

Entity relationship summary

affiliates 1───* clicks 1───* conversions *───1 payouts
                                  │
                                  └── (conversions.affiliate_id also
                                       points directly at affiliates,
                                       so unattributed-by-click but
                                       ref_code-matched conversions
                                       still resolve to an affiliate)

Money is always integer cents

Every amount column (amount_cents, commission_cents, payouts.amount_cents) is stored as an integer in the currency's smallest unit — never a float. refreal_money() in includes/helpers.php divides by 100 only at display time, avoiding floating-point rounding drift across thousands of conversions.

Attribution Engine

This is the part of Refreal that decides who gets credit, and whether they still get paid. It lives entirely in includes/attribution.php::refreal_record_conversion().

The two ways a sale gets attributed

1. Click-based (preferred)

The visitor's browser carried a refreal_via cookie (set by click.php when they first clicked an affiliate link) through to checkout, and your integration passed that cookie's value (the click_uid) into the provider webhook.

This is the only path that supports attribution window expiry, because it's the only path with a real click timestamp to measure against.

2. Referral-code-based (fallback)

No click_uid was available, but you had the affiliate's ref_code (e.g. hardcoded in your checkout, or manually entered). Refreal looks up the affiliate directly and attributes the sale to them — but since there's no click timestamp, the attribution window is never checked. The sale is always pending (never expired_unattributed) via this path.

Use click-based attribution whenever possible. Referral-code fallback exists for checkouts that can't easily carry a cookie value through to the payment provider (e.g. a phone/manual order where a rep enters the affiliate's code).

Resolving the attribution window

function refreal_attribution_window(?array $affiliate): int {
    if ($affiliate && !empty($affiliate['attribution_window_days'])) {
        return (int)$affiliate['attribution_window_days'];   // per-affiliate override
    }
    return (int)refreal_setting('attribution_window_days', 30); // global default
}

Set the override per affiliate from Admin → Affiliates → Edit → Attribution Window Override. Leave it blank to inherit the global value from Admin → Settings.

Worked examples

Assume global attribution window = 30 days.

Affiliate override Click date Conversion date Days elapsed Result
(none, uses 30) Jan 1 Jan 20 19 pending — commission owed
(none, uses 30) Jan 1 Feb 15 45 expired_unattributed — $0 commission, still logged
90 (VIP override) Jan 1 Feb 15 45 pending — within their 90-day window
(no click at all, ref_code only) — any — pending — window never checked
(no click, no ref_code, unknown code) — any — unattributed, affiliate_id = NULL

Commission calculation

function refreal_calc_commission(array $affiliate, int $amountCents): int {
    if ($affiliate['commission_type'] === 'fixed') {
        return (int)round($affiliate['commission_rate'] * 100); // dollars → cents
    }
    return (int)round($amountCents * ($affiliate['commission_rate'] / 100));
}
  • Percent affiliates: commission_rate is a percentage (e.g. 25 = 25%). Commission = amount_cents * rate / 100, rounded to the nearest cent.
  • Fixed affiliates: commission_rate is a flat dollar amount (e.g. 10 = $10.00 per sale), independent of the sale amount.

If a conversion lands as expired_unattributed, commission is forced to 0 regardless of type/rate — the sale amount is still recorded for your own revenue visibility, it's just not payable.

Idempotency

Every conversion insert is keyed on (source, external_id) with a unique index. If a webhook fires twice for the same external_id (Polar and Stripe both retry on non-2xx responses, and can occasionally double-send even on success), the second call returns:

{"status": "duplicate", "conversion_id": 1234, "affiliate_id": null}

...and no second row is written. This means you can safely return non-2xx from your own error handling during development without worrying about double-counting once you fix it and the provider retries.

Refunds

refreal_refund_conversion($source, $externalId) flips a conversion's status straight to refunded, regardless of its current status (even if it was already paid — Refreal doesn't automatically claw back a batch payout; that's a manual reconciliation step on your end if it happens after payment). The original amount_cents / commission_cents are left untouched for audit purposes — only status changes.

What "unattributed" actually costs you

An unattributed conversion means the webhook fired but Refreal had nothing to match it to — no click_uid resolved to a row in clicks, and no ref_code resolved to a row in affiliates. This usually means:

  • The checkout link isn't a recognized checkout domain and doesn't carry a data-refreal-param / data-refreal-checkout attribute, so tracker.js had no way to know to tag it — or a custom backend integration isn't passing metadata.refreal_click / client_reference_id / click_uid at all, or
  • The visitor's cookie was cleared/blocked before checkout, or
  • The ref_code sent doesn't match any affiliate's referral_code exactly (case-sensitive).

These rows are still logged (visible under the Unattributed filter on the Payouts ledger) so you can spot integration problems — a spike in unattributed conversions usually means something upstream broke.

API & Webhook Reference

Base URL below is written as https://yourdomain.com — substitute your actual deployment domain. All endpoints return Content-Type: application/json unless noted otherwise.


POST /click.php

Public, CORS-enabled. Called by tracker.js; you can also call it directly from your own frontend code if you're not using the snippet.

Headers

Content-Type: application/json

Body

{
  "ref": "JOHND93A1",
  "landing_url": "https://yoursite.com/pricing?via=JOHND93A1",
  "referrer": "https://twitter.com/"
}
Field Required Notes
ref Yes Affiliate's referral_code. Must belong to an active affiliate.
landing_url No Truncated to 2048 chars.
referrer No Truncated to 2048 chars.

Success — 200

{ "ok": true, "click_uid": "5135caa1-7a80-4ab4-ba65-d0f4ce5f546e", "cookie_days": 30 }

Also sets the refreal_via cookie server-side (same-site requests only — cross-origin callers should set it client-side using the returned click_uid and cookie_days, which is exactly what tracker.js does).

Errors | Status | Body | Cause | |---|---|---| | 400 | {"error": "Missing ref code"} | ref absent or empty | | 404 | {"error": "Unknown or inactive referral code"} | No active affiliate with that code | | 405 | {"error": "Method not allowed"} | Non-POST request |

CORS: controlled by Settings → CORS Allowed Origins (* by default). OPTIONS preflight requests get a 204 automatically.


GET /portal.php?token=<portal_token>

Public, no authentication beyond the token itself. Renders an HTML dashboard for one affiliate (their referral link, clicks, conversions, balances, and a payout-profile editor). Not a JSON API — this is a full page for the affiliate to bookmark.

  • token — the affiliate's portal_token (found/regenerated on Admin → Affiliates → Edit).
  • Unknown/invalid token → 404 with a plain "link not found" page.
  • Regenerating the token from the admin panel immediately invalidates the old URL.
  • If the affiliate's status is pending, the page shows an "awaiting review" notice instead of an active referral link.

POST /portal.php — update payout profile

The same page also accepts a same-origin form POST from the affiliate to update their own payout details (session-CSRF protected, using a token stored in the PHP session rather than requiring login).

Body (form-encoded)

csrf=<session csrf token, rendered into the page's form>
token=<portal_token, so the POST re-resolves the same affiliate>
action=update_payout_profile
payout_method=paypal|wise|bank|manual
paypal_email=... (if payout_method=paypal)
wise_email=... / wise_account_holder=... (if payout_method=wise)
bank_account_name=... / bank_name=... / bank_account_number=... / bank_routing_number=... / bank_swift=... (if payout_method=bank)
manual_notes=... (if payout_method=manual)

Only the fields relevant to the selected payout_method are persisted (see Database Schema → payout_details). Response is the same HTML page re-rendered with a "Payout details saved" confirmation.


GET/POST /signup.php

Public self-serve affiliate application form. GET renders the form (or a "signups are closed" message if disabled); POST submits it.

Gated entirely by two settings: - self_serve_signup_enabled — if 0, both GET and POST short-circuit to a "signups are closed" message; no application is ever recorded. - self_serve_requires_approval — if 1 (default), new applications are created with status = 'pending' and must be approved from Admin → Affiliates before their referral link works. If 0, they're active immediately.

Body (form-encoded, same-origin with session CSRF)

csrf=<session csrf token>
name=Jane Partner
email=jane@example.com
website=https://jane.example        (optional)
pitch=I run a newsletter...          (optional)
payout_method=paypal|wise|bank|manual
paypal_email=... / wise_email=... / bank_account_name=... etc. (matching payout_method)
company_website=                     (honeypot - must stay empty; bots that
                                       fill it in silently get a fake success
                                       page with nothing recorded)

On success: creates an affiliates row with signup_source = 'self_serve', sends the affiliate a welcome/pending-review email (refreal_mail_affiliate_welcome()), and — if Settings → Transactional Email → Email me when a new affiliate signs up is on — emails the admin (refreal_mail_admin_new_signup()).

Duplicate email addresses are rejected with an inline error (one affiliate row per email).


POST /webhooks/polar.php

Configure in your Polar.sh dashboard's webhook settings. See Integration Guide: Polar.sh for the full setup walkthrough.

Auth: Standard Webhooks signature verification using polar_webhook_secret (Settings page), checked against the webhook-id, webhook-timestamp, and webhook-signature headers. Refreal never generates this value — paste the secret Polar's dashboard shows you after creating the webhook endpoint into Settings → Integrations → Polar.sh → Polar Webhook Secret and click Save Settings. If polar_webhook_secret is empty, signature verification is skipped (useful for local testing — do not leave it unset in production).

Handled event types

type Behavior
order.paid Records a conversion via refreal_record_conversion()
order.refunded Marks the matching conversion refunded
(anything else) Acknowledged with 200 and {"skipped": "<type>"}, no-op

Expected payload shape (from Polar, abbreviated):

{
  "type": "order.paid",
  "data": {
    "id": "order_xxx",
    "amount": 4900,
    "currency": "usd",
    "customer": { "email": "buyer@example.com" },
    "metadata": {
      "reference_id": "5135caa1-7a80-4ab4-ba65-d0f4ce5f546e"
    }
  }
}

Attribution fields read, in order of precedence: 1. data.metadata.refreal_click (set by a custom backend integration that calls the Checkout API directly) 2. data.metadata.reference_id — this is the one populated automatically when checkout happens via a plain Checkout Link, since Polar copies its own ?reference_id= query parameter (the one tracker.js tags checkout links with) onto the Checkout Session and Order metadata for you 3. data.metadata.refreal_ref (referral-code fallback, no click timestamp so attribution can't expire)

Metadata can be set at the top level (data.metadata) or nested under data.checkout.metadata — both are checked.

Response — 200

{ "ok": true, "result": { "status": "pending", "conversion_id": 12, "affiliate_id": 3 } }

Error — 401

{ "error": "Invalid signature" }

POST /webhooks/stripe.php

Configure as a Stripe webhook endpoint. See Integration Guide: Stripe.

Auth: Stripe's standard Stripe-Signature header scheme, verified against stripe_webhook_secret with no SDK dependency (implemented inline in webhooks/stripe.php). Stripe issues its own signing secret (whsec_...) per endpoint — paste that exact value into Settings → Integrations → Stripe → Stripe Signing Secret (whsec_...) and click Save Settings. Refreal never generates or offers to regenerate a value here, since a self-generated secret would never match what Stripe actually signs with. Verification is skipped if the secret is empty.

Handled event types

type Behavior
checkout.session.completed Records a conversion
charge.refunded Marks the matching conversion refunded, matched via payment_intent
(anything else) Acknowledged, no-op

Attribution fields read, in order of precedence: 1. data.object.metadata.refreal_click 2. data.object.client_reference_id 3. data.object.metadata.refreal_ref (referral-code fallback)

Expected payload shape (abbreviated):

{
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_xxx",
      "amount_total": 4900,
      "currency": "usd",
      "customer_details": { "email": "buyer@example.com" },
      "client_reference_id": "5135caa1-7a80-4ab4-ba65-d0f4ce5f546e",
      "metadata": {}
    }
  }
}

Response — 200

{ "ok": true, "result": { "status": "pending", "conversion_id": 12, "affiliate_id": 3 } }

POST /webhooks/generic.php

For any custom/homegrown checkout. CORS-enabled (so you can call it directly from a trusted backend or, less ideally, client-side with the key embedded — server-side is strongly recommended).

Auth: API key, either header or body field:

X-Refreal-Api-Key: <generic_api_key from Settings>

or

{ "api_key": "<generic_api_key>" }

Body — recording a sale

{
  "click_uid": "5135caa1-7a80-4ab4-ba65-d0f4ce5f546e",
  "ref_code": "JOHND93A1",
  "external_id": "order_98213",
  "customer_email": "buyer@example.com",
  "amount_cents": 4900,
  "currency": "USD",
  "event": "sale"
}
Field Required Notes
external_id Yes Your order/transaction ID — idempotency key
click_uid One of these two From the refreal_via cookie, read server-side at checkout
ref_code One of these two Affiliate's referral code, if no click_uid available
amount_cents No (defaults 0) Integer, smallest currency unit
currency No (defaults USD) ISO 4217
customer_email No
event No (defaults sale) "sale" or "refund"

Body — recording a refund

{ "api_key": "...", "event": "refund", "external_id": "order_98213" }

Response — 200

{ "ok": true, "result": { "status": "pending", "conversion_id": 12, "affiliate_id": 3 } }

Errors | Status | Body | Cause | |---|---|---| | 400 | {"error": "external_id is required"} | Missing on both sale and refund | | 400 | {"error": "Provide click_uid or ref_code"} | Neither present on a sale event | | 401 | {"error": "Invalid or missing API key"} | Wrong/absent key | | 405 | {"error": "Method not allowed"} | Non-POST request |


Admin panel routes (session-authenticated, not a public API)

These are server-rendered HTML pages, not JSON endpoints — listed here for completeness. All require an active admin session (admin/login.php) and a valid CSRF token on every POST.

Route Purpose
GET/POST admin/login.php Session login against master_password_hash
GET admin/logout.php Destroys session
GET admin/index.php Overview dashboard
GET/POST admin/affiliates.php List/create/update/delete affiliates, approve/reject pending applications, regenerate portal tokens
GET/POST admin/settings.php Global settings, signup toggle, mailer config, password change, webhook secret entry/rotation
GET/POST admin/payouts.php Conversion ledger, approve, mark paid
GET admin/payouts.php?export=csv&status=approved\|paid CSV download

Result object shape (internal, returned by refreal_record_conversion())

Every webhook returns this same shape inside result:

{
  status: "pending" | "approved" | "paid" | "refunded"
        | "expired_unattributed" | "unattributed" | "duplicate",
  conversion_id: number | null,
  affiliate_id: number | null
}

"duplicate" is only returned in-process (not a stored conversions.status value) — it means the idempotency check matched an existing row and nothing new was written.

Integration Guide: Polar.sh

1. Add the tracker to your site

<script src="https://yourdomain.com/tracker.js"
        data-refreal-endpoint="https://yourdomain.com"></script>

Share affiliate links as https://yoursite.com/?via=CODE. The snippet captures the click and stores a refreal_via cookie automatically.

2. Pass the click through to Polar Checkout

If you're using a plain Polar Checkout Link (the URL you get from Polar's dashboard, e.g. https://buy.polar.sh/xxx or https://polar.sh/checkout/xxx, linked from a button or a "Buy now" link — whether it opens in a new tab or is intercepted by Polar's own embed script) — there's nothing to build. tracker.js already scans the page for links to a *.polar.sh checkout URL and appends ?reference_id=<click_uid> to the href automatically, and Polar copies reference_id onto the Checkout Session (and the resulting Order) for you. Just make sure the tracker snippet from step 1 is loaded on any page that has one of these links, including your pricing/checkout page — not just the original landing page.

If your backend creates the Checkout Session itself via the Polar API (rather than linking to a static Checkout Link), read the refreal_via cookie server-side and attach it as metadata instead:

// Example: creating a Polar checkout session server-side
const clickUid = getCookie(req, 'refreal_via'); // your own cookie-read helper

const checkout = await polar.checkouts.create({
  products: ['prod_xxx'],
  metadata: {
    refreal_click: clickUid ?? undefined,
  },
});

If you can't read the cookie server-side (e.g. a client-only integration), pass the referral code instead as a fallback — commission still applies, but attribution-window expiry won't be checked for that sale:

metadata: { refreal_ref: 'JOHND93A1' }

3. Configure the webhook in Polar

  1. Polar Dashboard → your organization → Webhooks → Add Endpoint.
  2. URL: https://yourdomain.com/webhooks/polar.php
  3. Events: enable order.paid and order.refunded (that's all Refreal listens for; other events are safely ignored).
  4. Polar will show you a signing secret after the endpoint is created — copy that exact value into Refreal Admin → Settings → Integrations → Polar.sh → Polar Webhook Secret and click Save Settings. Refreal never generates or offers to regenerate this value itself, since a self-generated secret would never match what Polar actually signs with — Polar's dashboard is always the source of truth here.

4. Test it

Polar lets you send a test order.paid event from the dashboard. After sending one:

  1. Check Admin → Overview — a new row should appear under Recent Conversions, tagged Polar.
  2. If it shows as unattributed, double-check your checkout link is actually being tagged — open dev tools on your storefront and hover the checkout link/button, or inspect its rendered href, and confirm it ends with ?reference_id=.... Test events sent from the Polar dashboard UI typically won't have real metadata, so this is expected for dashboard test sends regardless. Use a real (or sandboxed) checkout flow with a tracked click to see full attribution end to end.

5. Currency & amounts

Refreal reads data.amount (falling back to data.net_amount) as an integer already in the smallest currency unit, and data.currency, upper-cased. No conversion is applied — make sure your commission math expectations match whichever amount field your Polar account actually sends (gross vs net can matter if you have platform fees).

6. Refunds

Polar's order.refunded event is matched back to the original conversion by data.id (the same order ID used for order.paid). Refreal flips that conversion's status to refunded — this does not claw back a payout automatically if it was already marked paid; reconcile that manually if refunds happen post-payout.

Integration Guide: Stripe

1. Add the tracker to your site

<script src="https://yourdomain.com/tracker.js"
        data-refreal-endpoint="https://yourdomain.com"></script>

Share affiliate links as https://yoursite.com/?via=CODE.

2. Pass the click through to Stripe Checkout

If you're using a plain Stripe Payment Link (a static URL from Stripe's dashboard, e.g. https://buy.stripe.com/xxx, linked from a button or "Buy now" link) — there's nothing to build. tracker.js already scans the page for links to a buy.stripe.com URL and appends ?client_reference_id=<click_uid> to the href automatically; Stripe copies that straight onto the Checkout Session and sends it back in the checkout.session.completed webhook. Just make sure the tracker snippet from step 1 is loaded on any page carrying one of these links, including your pricing/checkout page — not just the original landing page.

If your backend creates the Checkout Session itself via the Stripe API, set either client_reference_id or metadata.refreal_click to the visitor's refreal_via cookie value instead:

const clickUid = getCookie(req, 'refreal_via');

const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  client_reference_id: clickUid || undefined,
  // or, equivalently:
  // metadata: { refreal_click: clickUid },
  success_url: 'https://yoursite.com/success',
  cancel_url: 'https://yoursite.com/cancel',
});

metadata.refreal_click is checked first, then client_reference_id (what the automatic tagging above sets), then metadata.refreal_ref as a referral-code fallback (no attribution window checking on that last path — see Attribution Engine).

3. Configure the webhook in Stripe

  1. Stripe Dashboard → Developers → Webhooks → Add endpoint.
  2. URL: https://yourdomain.com/webhooks/stripe.php
  3. Events to send: checkout.session.completed and charge.refunded.
  4. Stripe shows you a signing secret (whsec_...) once the endpoint is created — copy that value into Refreal Admin → Settings → Integrations → Stripe → webhook secret field. Refreal's own generated secret is just a placeholder default; Stripe's real signing secret is what actually needs to be pasted in for signature verification to pass.

Important: unlike Polar, Refreal does not generate the secret Stripe expects — Stripe issues its own whsec_... value per endpoint. Overwrite Refreal's stripe_webhook_secret setting with Stripe's value, not the other way around.

4. Test it

Use the Stripe CLI for local testing:

stripe listen --forward-to https://yourdomain.com/webhooks/stripe.php
stripe trigger checkout.session.completed

Or use Stripe's dashboard "Send test webhook" feature. Then check Admin → Overview for a new Stripe-tagged conversion.

5. Currency & amounts

Refreal reads data.object.amount_total (already in the smallest currency unit — cents for USD) and data.object.currency, upper-cased. This is the total charged, including tax if Stripe Tax is enabled — decide whether your commission structure should be based on gross or net and adjust commission_rate accordingly, since Refreal doesn't subtract tax/fees automatically.

6. Refunds

Refreal records conversions keyed by the Checkout Session's PaymentIntent ID when one is present (payment-mode sessions), falling back to the Checkout Session ID otherwise (e.g. setup/subscription mode with no PaymentIntent yet). charge.refunded events are matched back the same way, via data.object.payment_intent — so for standard one-time payment checkouts, refunds are matched and marked refunded automatically with no extra setup.

Subscription-mode sessions (no immediate PaymentIntent) won't have a charge.refunded event fire against the original session ID the same way — if you sell subscriptions and need refund tracking on those, extend webhooks/stripe.php to also listen for invoice.payment_succeeded / relevant subscription refund events and match on the invoice's payment_intent.

Integration Guide: Custom Checkout

Use this if you built your own checkout (not Polar or Stripe) — e.g. a manual invoice flow, a different payment processor, or an internal billing system.

1. Add the tracker to your site

<script src="https://yourdomain.com/tracker.js"
        data-refreal-endpoint="https://yourdomain.com"></script>

Server-side, when the order is placed, read the refreal_via cookie from the incoming request. That value is the click_uid to send to Refreal.

// Example, plain PHP
$clickUid = $_COOKIE['refreal_via'] ?? null;
// Example, Node/Express
const clickUid = req.cookies.refreal_via ?? null;

If you can't read cookies (e.g. a phone order entered by staff), ask for the affiliate's referral code directly and send ref_code instead.

3. Fire the webhook when payment succeeds

curl -X POST https://yourdomain.com/webhooks/generic.php \
  -H "Content-Type: application/json" \
  -H "X-Refreal-Api-Key: <your generic_api_key from Settings>" \
  -d '{
    "click_uid": "5135caa1-7a80-4ab4-ba65-d0f4ce5f546e",
    "external_id": "order_98213",
    "customer_email": "buyer@example.com",
    "amount_cents": 4900,
    "currency": "USD",
    "event": "sale"
  }'

external_id must be unique per transaction — it's what makes retries safe. Use your own order/invoice ID.

4. Fire it again on refund

curl -X POST https://yourdomain.com/webhooks/generic.php \
  -H "Content-Type: application/json" \
  -H "X-Refreal-Api-Key: <your generic_api_key>" \
  -d '{ "external_id": "order_98213", "event": "refund" }'

5. Language examples

Python

import requests

requests.post(
    "https://yourdomain.com/webhooks/generic.php",
    headers={"X-Refreal-Api-Key": GENERIC_API_KEY},
    json={
        "click_uid": click_uid,
        "external_id": order_id,
        "customer_email": customer_email,
        "amount_cents": amount_cents,
        "currency": "USD",
        "event": "sale",
    },
    timeout=5,
)

Node.js

await fetch('https://yourdomain.com/webhooks/generic.php', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Refreal-Api-Key': process.env.REFREAL_GENERIC_API_KEY,
  },
  body: JSON.stringify({
    click_uid: clickUid,
    external_id: orderId,
    customer_email: customerEmail,
    amount_cents: amountCents,
    currency: 'USD',
    event: 'sale',
  }),
});

6. Best practices

  • Call this from your backend, after payment is confirmed — never from the client immediately on checkout submission, or you'll record sales that later fail/decline.
  • Always send amount_cents as an integer (e.g. $49.00 → 4900), never a float — this avoids rounding drift.
  • Keep the API key server-side. If you must call this from a browser (not recommended), be aware the key would be exposed in devtools; use a thin backend proxy instead.
  • Retry safely. Because of the (source, external_id) uniqueness constraint, you can retry a failed call with the same external_id without creating duplicate conversions.

Deployment

Requirements recap

  • PHP 8.1+ with pdo_sqlite and sqlite3 extensions (bundled by default in almost every PHP install/distro package).
  • A writable data/ directory.
  • HTTPS in production — webhook signatures and the affiliate portal token both rely on the transport being secure.

No Composer, no npm, no build step — upload and go.

Option A: Shared hosting (cPanel, Plesk, etc.)

  1. Upload the refreal/ folder into your webroot (or a subdomain like track.yourdomain.com pointed at its own folder).
  2. Ensure data/ is writable — chmod 775 data via file manager or SSH.
  3. Most shared hosts serve .htaccess automatically (Apache). The included .htaccess blocks direct access to includes/. If your host doesn't support .htaccess (rare), see the nginx section below for the equivalent block rules to configure through your host's panel.
  4. Visit /admin/login.php to trigger first-run setup.

Option B: Apache (self-managed VPS)

Minimal vhost:

<VirtualHost *:443>
    ServerName track.yourdomain.com
    DocumentRoot /var/www/refreal

    <Directory /var/www/refreal>
        AllowOverride All
        Require all granted
    </Directory>

    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/track.yourdomain.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/track.yourdomain.com/privkey.pem
</VirtualHost>

AllowOverride All is required for the included .htaccess (which blocks includes/) to take effect. Make sure mod_rewrite is enabled:

sudo a2enmod rewrite
sudo systemctl reload apache2

Option C: nginx + php-fpm

nginx ignores .htaccess, so replicate its rules directly, including the clean-URL rewrite (see Clean URLs):

server {
    listen 443 ssl http2;
    server_name track.yourdomain.com;
    root /var/www/refreal;
    index index.php;

    ssl_certificate     /etc/letsencrypt/live/track.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/track.yourdomain.com/privkey.pem;

    # Block the SQLite DB, one-time password file, and internal includes
    location ~ ^/(data|includes)/ {
        deny all;
        return 404;
    }

    # Never serve schema.sql directly
    location = /schema.sql {
        deny all;
        return 404;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.1-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # Clean URLs: try the exact path, then a directory index, then the
    # same path with .php appended (e.g. /admin/settings -> admin/settings.php),
    # falling through to nginx's own 404 if none of those exist. This
    # covers webhooks/click.php too (e.g. /webhooks/polar also resolves),
    # but nothing here forces a redirect away from the literal .php path -
    # both forms keep working, same as under Apache. The original query
    # string is preserved automatically by nginx when it internally
    # redirects to the matched .php file.
    location / {
        try_files $uri $uri/ $uri.php =404;
    }
}

Adjust the fastcgi_pass socket path to match your PHP-FPM version.

Option D: Local development

cd refreal
php -S localhost:8000 router.php

Visit http://localhost:8000/admin/login. The built-in dev server ignores .htaccess entirely (same caveat as nginx), so the bundled router.php emulates the same clean-URL behavior in front of it — without it, only the .php paths would resolve locally. Either way, don't use php -S for anything internet-facing.

Permissions checklist

data/                 → writable by the PHP process user (775 or 755+group)
data/refreal.sqlite   → created automatically, writable
config.php            → read-only is fine, no secrets stored in this file

All actual secrets (master password hash, webhook secrets, API key) live in the settings table inside data/refreal.sqlite, not in any PHP file — so config.php is safe to commit to a git repo if you're version controlling your deployment; data/ should be gitignored.

Backups

The entire application state is one file: data/refreal.sqlite. Back it up like any SQLite database:

sqlite3 data/refreal.sqlite ".backup data/backup-$(date +%F).sqlite"

Run that on a cron schedule and ship the backups off-server (S3, another host, etc.). WAL mode means there may also be -wal and -shm sidecar files present during operation — .backup handles checkpointing correctly; a raw cp of just the .sqlite file while the app is live can miss uncommitted WAL data, so prefer the .backup command or stop the app first.

Updating

Refreal has no auto-updater. To upgrade: replace all application files (everything except data/) with the new version, then load any admin page — includes/db.php only runs schema.sql when the settings table is entirely missing, so schema migrations for existing installs are not automatic. If a future version changes schema.sql, check its changelog for any ALTER TABLE statements you need to run manually against data/refreal.sqlite before deploying.

Security

What Refreal protects by default

Concern Mitigation
Admin panel access Session-based auth, bcrypt password hash (password_hash/password_verify), session ID regenerated on login
CSRF on admin actions Every mutating POST form includes a per-session token, checked with hash_equals() before any write
Webhook forgery (Polar) Standard Webhooks HMAC signature, verified against polar_webhook_secret
Webhook forgery (Stripe) Stripe's Stripe-Signature HMAC scheme, verified against stripe_webhook_secret
Webhook forgery (custom) Static API key, compared with hash_equals() (timing-safe)
Affiliate portal access Random 40-hex-char token per affiliate, regeneratable, no way to enumerate other affiliates from a valid token
Direct access to data/ Blocked via .htaccess (Apache) — you must replicate this on nginx, see Deployment
Direct access to includes/ Blocked via .htaccess (Apache) / nginx rule
Visitor IP storage Never stored raw — SHA-256 hashed with a salt before insertion into clicks.ip_hash
SQL injection All queries use PDO prepared statements with bound parameters, no string concatenation into SQL
XSS in admin/portal pages All dynamic output passed through e() (htmlspecialchars) before rendering

What you're responsible for

  • HTTPS. Nothing above matters if traffic is unencrypted — webhook secrets, the admin session cookie, and affiliate portal tokens are all bearer credentials that must not travel in plaintext. Terminate TLS in front of the app (Let's Encrypt + your web server, or a CDN/proxy).
  • nginx .htaccess equivalents. .htaccess only works under Apache. If you're on nginx, you must add the deny all blocks for data/ and includes/ yourself — see Deployment. Until you do, data/refreal.sqlite (and the one-time password file, if not yet deleted) would be directly downloadable.
  • Deleting data/INITIAL_PASSWORD.txt after your first login. It's never regenerated, but it does sit in a directory that should already be blocked from the web — deleting it is defense in depth, not the only layer.
  • Rotating secrets if you suspect a leak. The generic checkout API key can be regenerated with one click from Admin → Settings and the old value stops working immediately. Polar and Stripe secrets aren't Refreal's to rotate — if you suspect one of those leaked, generate a new webhook/signing secret from that provider's own dashboard first, then paste the new value into Refreal's Settings page to match.
  • CORS scope. cors_allowed_origins defaults to * so click.php and webhooks/generic.php work out of the box from any domain. If your affiliate links only ever originate from your own site(s), narrow this to a comma-separated allowlist in Settings — it doesn't materially change security (these endpoints don't expose sensitive data and are meant to be called cross-origin), but it's tidier and blocks casual abuse/scraping of your click endpoint from unrelated domains.
  • Rate limiting. Refreal doesn't rate-limit click.php or the webhook endpoints at the application layer. If you're worried about click-flooding (inflating an affiliate's numbers, or just noise), add rate limiting at your reverse proxy / CDN layer (e.g. nginx limit_req, Cloudflare rate limiting rules).
  • Backups. See Deployment → Backups. A compromised or corrupted data/refreal.sqlite with no backup means losing every affiliate, click, and conversion record.

Threat model notes

  • Affiliate portal tokens are bearer tokens, not passwords — anyone with the URL can view that affiliate's dashboard. This is by design (no login friction for affiliates), but means you should treat the URL like a password when sharing it, and regenerate it if it's ever posted publicly by mistake.
  • The generic API key is a single shared secret for all custom checkout integrations, not scoped per integration. If you have multiple independent systems posting to webhooks/generic.php, they all share one key — rotating it breaks all of them simultaneously. This is intentional simplicity for a single-tenant tool; if you need per-integration key scoping, that's an extension point in webhooks/generic.php you'd need to build (e.g. a table of named API keys instead of one setting).
  • No two-factor authentication on the admin login. This is a single-operator tool behind one master password — if that's not sufficient for your risk tolerance, put it behind an additional layer (VPN, IP allowlist, or a reverse-proxy auth layer like Cloudflare Access / Authelia) rather than expecting the app itself to provide it.

Reporting a problem

Refreal is source-available code you're self-hosting — there's no vendor to report a vulnerability to. Review includes/, webhooks/, and admin/ yourself (they're short, readable files by design — see Architecture) before exposing this to real payment volume, and patch anything you find directly.

Admin Panel Guide

All screens live under /admin/ (or <your-subfolder>/admin/ if deployed in a subdirectory — every link in the app resolves itself automatically, see Architecture → Subdirectory-safe routing) and require login at admin/login.php.

The whole admin panel is responsive: on narrow screens the sidebar collapses behind a hamburger button (top-left) and slides in as an overlay; data tables switch to a stacked card layout instead of horizontal-scrolling tables.

Overview (admin/index.php)

Landing page after login. If there are affiliate applications awaiting review, a banner appears at the top linking straight to the pending queue. Below that, four top-line metrics:

  • Total Clicks — every row in clicks, all-time.
  • Conversions — count of conversions with status other than unattributed (i.e. sales that resolved to a known affiliate, regardless of pay status), plus the conversion rate (conversions ÷ clicks).
  • Revenue Tracked — sum of amount_cents for conversions in pending / approved / paid status (refunded and expired/unattributed sales excluded).
  • Unpaid Balance — sum of commission_cents for pending + approved conversions — this is what you currently owe across all affiliates.

Below that: a Recent Conversions feed (last 8, any status) and a Top Affiliates leaderboard ranked by unpaid commission.

Affiliates (admin/affiliates.php)

Status tabs at the top: All / Pending / Active / Paused. Table (or card list on mobile) of every affiliate with click/conversion counts, payout destination, and unpaid balance inline.

Reviewing applications

Affiliates that signed up through the public signup.php form (when Settings → Affiliate Signup → Require manual approval is on) show up under the Pending tab with Approve / Reject actions right in the row: - Approve flips their status to active (their referral link starts working immediately) and emails them a confirmation with their referral link and portal URL. - Reject deletes the application entirely.

Creating and editing affiliates

  • + New Affiliate — opens a modal: name, email, commission type/rate, optional attribution window override, and a payout method with method-specific fields (PayPal email / Wise email + account holder / bank details / free-text notes). The referral code and portal token are generated automatically. Manually-created affiliates are always active immediately — no approval step.
  • Edit (per row, active/paused affiliates) — opens that affiliate's modal, showing:
  • Their portal link (copyable), with a Regenerate link button that immediately invalidates the old URL.
  • Editable name, email, commission type/rate, attribution override, status (active/paused), payout method + details, and internal notes.
  • Delete affiliate at the bottom — detaches (not deletes) their historical conversions (affiliate_id set to NULL), so revenue history isn't lost, but they disappear from the list and their portal link stops working.

Affiliates can also update their own payout details directly from their portal page — see Portal below — so you don't have to relay bank/PayPal changes manually.

Settings (admin/settings.php)

Five sections:

  1. General & Attribution — store/landing page URL (where referral links point), cookie expiry (days), global attribution window (days), default currency, CORS allowed origins, Program Name (affiliate-facing — see How attribution works), and Minimum Payout Threshold (informational — shown to affiliates in their portal, doesn't gate anything you do in Admin → Payouts).
  2. Affiliate Signup — master on/off switch for public self-serve signup at signup.php, whether new signups require approval before going live, and the default commission type/rate applied to them.
  3. Integrations — for each of Polar, Stripe, and Custom Checkout: the exact webhook URL to paste into that provider's dashboard (with a Copy URL button), and a way to set the secret. Polar and Stripe each have a single paste-and-Save Settings field — you paste in the real secret that provider's dashboard shows you after you create the endpoint there; Refreal never auto-generates or offers to regenerate a value for either of these, since only the provider knows what it actually signs with. The Generic API key is the only Refreal-controlled secret, shown masked with a Show/Hide toggle, a Copy button, and Regenerate API Key. Also shows the ready-to-paste <script> tag for tracker.js.
  4. Transactional Email — pick a mailer driver (PHP mail(), raw SMTP, or disabled), set the From address/name, SMTP credentials if applicable, whether to notify yourself on new signups and where, and a Send Test button to confirm delivery before relying on it.
  5. Master Password — change your own login password (requires entering the current one).

Every regenerate/save action on a secret takes effect immediately — update the corresponding provider dashboard / integration code right after, or webhooks will start failing signature verification.

Payouts (admin/payouts.php)

The conversion ledger — this is where day-to-day operations happen.

  • Status tabs at the top (All / Pending / Approved / Paid / Refunded / Expired) filter the table below, with live counts.
  • Export CSV buttons pull approved or paid conversions into a CSV that now includes each affiliate's payout method and destination (PayPal email / Wise email / bank account number / manual notes) alongside the usual sale/commission columns — hand this straight to PayPal Mass Pay, Wise batch transfer, or your own process without cross-referencing the Affiliates page separately.
  • Checkbox + Approve Selected — moves checked pending rows to approved. Only pending/approved rows show a checkbox.
  • Checkbox + Mark Paid — moves checked approved rows to paid, grouping them into one payouts record per affiliate, and emails each affected affiliate a "payout sent" receipt (if a mailer is configured — failures here never block the payout itself). You can optionally set a payment method (PayPal/Wise/Bank/Manual) and a note before confirming.
  • Recent Payout Batches at the bottom shows the last 10 batches created this way, for a quick audit trail.

Affiliate Portal (portal.php)

Not under /admin/ — this is the token-gated page you share with each affiliate (portal.php?token=..., copyable from Affiliates → Edit). No login required. Affiliates can:

  • Copy their referral link and see their referral code / commission rate / attribution window at a glance.
  • See live stats: clicks, conversions, unpaid balance, total paid — and, if you've set a Minimum Payout Threshold in Settings, a line under their unpaid balance showing how close they are to it (or that they've already cleared it), so they don't have to ask.
  • See each conversion labeled with your Program Name if you've set one (e.g. "Acme sale"), instead of the raw provider name.
  • Manage their own payout profile — switch between PayPal / Wise / Bank / Manual and fill in the relevant details themselves, so payout info stays current without you relaying it back and forth by email.
  • Browse their own conversion history with status badges.
  • If their application is still pending, they see a clear "awaiting review" notice instead of a live referral link.

Typical weekly/monthly workflow

  1. Open Affiliates → Pending and clear out any new applications (approve or reject).
  2. Open Payouts, filter to Pending.
  3. Review the list — spot-check any unattributed/expired spikes (tab over to those filters) since a sudden increase usually means an integration broke.
  4. Select the legitimate pending rows → Approve Selected.
  5. Switch to the Approved tab, select the batch you're ready to pay → Mark Paid, choosing method + note. Affected affiliates get an email automatically.
  6. Export CSV (status=paid) — payout destinations are already in the file — and feed it into your actual payment rail (PayPal Mass Pay / Wise batch / bank transfer).
  7. Repeat next cycle.

Troubleshooting / FAQ

I logged in but never saw data/INITIAL_PASSWORD.txt — how do I get in?

The file is written the first time any request touches the database (not necessarily the very first page load — bootstrap happens lazily on first DB access, which is any page except a bare GET to the login form). Trigger it by submitting the login form once (even with a wrong password), or by visiting click.php, then check data/ again. If the file still isn't there, check that data/ is writable by the PHP process (chmod 775 data).

I forgot the master password and deleted INITIAL_PASSWORD.txt.

There's no password reset flow (single-tenant, no email system). Connect to data/refreal.sqlite directly (via sqlite3 CLI or any SQLite browser) and reset the hash manually:

php -r "echo password_hash('newpassword123', PASSWORD_DEFAULT), PHP_EOL;"
sqlite3 data/refreal.sqlite "UPDATE settings SET value='<hash from above>' WHERE key='master_password_hash';"

Conversions are showing up as unattributed.

Means neither a click_uid nor a ref_code matched anything. Checklist: - Is tracker.js actually loaded on the page the affiliate link lands on? Check browser devtools → Network for a request to click.php. - Is tracker.js also loaded on the page(s) with the actual checkout link/button — not just the original landing page? It has to be present there to tag the link with ?reference_id= / ?client_reference_id= right before the click. - If the checkout link is on a domain tracker.js doesn't recognize (a custom domain, a white-labeled checkout, etc.), does it have a data-refreal-param or data-refreal-checkout attribute so it gets tagged anyway? Inspect the link's rendered href in devtools to confirm the query param actually made it on. - If it's a custom backend integration instead of a plain checkout link, is it reading the refreal_via cookie and passing it through as metadata.refreal_click / client_reference_id / click_uid? See the relevant integration guide. - Does the ref_code you're sending exactly match an affiliate's referral_code (case-sensitive)? Check Admin → Affiliates. - Is the affiliate's status active? click.php rejects clicks for paused affiliates outright (404).

Conversions are showing up as expired_unattributed.

The click happened, but the sale came in after the attribution window closed. Either the buyer took a long time to purchase, or your window is set too short for your sales cycle. Adjust the global window in Settings, or set a longer per-affiliate override on Affiliates → Edit for partners whose audience converts slowly (see Attribution Engine).

Stripe/Polar webhook returns 401.

Signature mismatch. Double-check: - Stripe: you pasted Stripe's signing secret (shown once when you create the endpoint in their dashboard) into Refreal's Settings — not the other way around. - Polar: Refreal generates its own secret; paste that value into Polar's webhook configuration. - You're not accidentally re-signing or modifying the raw request body anywhere in front of the app (a proxy that re-encodes JSON will break signature verification, since it's computed over the exact raw bytes).

A webhook fired twice and I'm worried about double-counting.

You shouldn't be — every conversion is keyed on (source, external_id) with a unique DB constraint. A duplicate call returns {"status": "duplicate", ...} and writes nothing. See Attribution Engine → Idempotency.

The token in the URL doesn't match any affiliate's current portal_token. Either it was mistyped, or it was regenerated since the link was shared (regenerating immediately invalidates the old one — see Admin → Affiliates → Edit → Regenerate link).

Can I run this on MySQL/Postgres instead of SQLite?

Not out of the box, but the port is mechanical — everything goes through PDO and plain SQL with no ORM. You'd rewrite includes/db.php's connection string and swap the SQLite-specific syntax in schema.sql (datetime('now') defaults, INSERT OR IGNORE) and includes/helpers.php (ON CONFLICT ... DO UPDATE is actually standard-ish and works on Postgres too; MySQL needs ON DUPLICATE KEY UPDATE instead). See Architecture → Why SQLite.

Can one affiliate have multiple referral codes?

Not natively — one referral_code per affiliate row. If you need multi-channel tracking per affiliate (e.g. separate codes for Twitter vs email), the simplest approach is creating multiple affiliate rows that share the same name/email/payout details but different codes, and manually summing their balances at payout time. A cleaner extension would add a separate referral_codes table with a foreign key to affiliates — not built in currently.

How do I change currency per-affiliate?

You can't — currency is a single global default setting (Settings → Default Currency), applied to conversions as reported by each provider. If your store sells in multiple currencies, conversions are still recorded correctly per-transaction (each conversion stores its own currency from the webhook payload), but the dashboard's aggregate totals sum raw amount_cents across currencies without conversion — treat multi-currency dashboard totals as approximate unless all your sales are in one currency.

Is there an audit log of admin actions?

Not a dedicated one, but conversions.raw_payload stores the full original webhook JSON for every conversion (useful for debugging / disputes), and payouts records who/when/how much was batched for payment. There's no login/action audit trail beyond that.

They shouldn't — every internal link (nav, redirects, portal URLs, webhook URLs shown in Settings) is generated by base_url() / full_url() in includes/helpers.php, which detects the deployment path automatically at request time. If something's still pointing at the wrong place, check whether that link was hardcoded outside those helpers (e.g. in a customization you made) — see Architecture → Subdirectory-safe routing.

Check their status on Admin → Affiliates — click.php only accepts clicks for status = 'active' affiliates. If they're still showing pending, the approval didn't go through (check for a flash error, or retry). If they're active and it's still not working, walk through the unattributed troubleshooting steps above instead — that's a different problem (tracking, not approval).

Self-serve signup emails aren't arriving.

Check Settings → Transactional Email: confirm the driver isn't set to disabled, and use the Send Test button to isolate whether it's a mailer configuration problem or something specific to the signup flow. If you're on PHP Mail (basic), note that many hosts block it outright or the message goes out but lands in spam (Gmail especially, since there's no authenticated sending domain behind a bare mail() call) — this can look like "nothing arrived" even when the Send Test reports success. Switch to SMTP (recommended) and point it at any standard provider (Gmail, SendGrid, Mailgun, etc.) for reliable delivery — see Architecture → Zero-dependency transactional mailer. Failures are logged via PHP's error_log(), so check your server's PHP error log for the specific reason if a test send fails silently.

Can affiliates change their own commission rate or attribution window?

No — only their payout method/details are editable from the portal. Commission rate, attribution window override, and status are admin-only fields on Affiliates → Edit, by design (you don't want affiliates setting their own commission).

Stripe webhook still returns 401 after I saved the secret.

Make sure you used the Settings → Integrations → Stripe → paste field and clicked Save next to it. Stripe's whsec_... value must be pasted in verbatim from the Stripe dashboard; Refreal never generates a Stripe-compatible one on its own, since Stripe is the one signing the requests.

Clean URLs (/admin/settings instead of /admin/settings.php) give a 404.

This means the request never reached PHP's rewrite fallback — check which server you're on: - Apache: confirm mod_rewrite is enabled and the app's directory has AllowOverride All (many custom Apache installs default to AllowOverride None, which silently ignores the bundled .htaccess entirely). See Deployment → Apache. - nginx: .htaccess is never read by nginx — you need the equivalent try_files block from Deployment → nginx added to your server config directly. - php -S (local dev): the built-in server ignores .htaccess too. Run it with the bundled router instead: php -S localhost:8000 router.php — without router.php, only the .php paths will resolve.

In every case, the original .php paths keep working regardless (e.g. /admin/settings.php always works) — clean URLs are an addition, not a replacement, so this is never a "the app is broken" situation, just a missing rewrite layer for the extension-less form specifically.