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_sqliteandsqlite3extensions 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-timeINITIAL_PASSWORD.txtthere on first run).
No database server, no composer install, no npm install.
2. Install
- 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. - Make sure
data/is writable by the PHP process:chmod 775 data - Visit
admin/login.phpunder wherever you put it (e.g.https://yourdomain.com/admin/login.phporhttps://yourdomain.com/tools/refreal/admin/login.php). On first load, Refreal automatically creates the SQLite database fromschema.sqland generates: - A random master password (written once todata/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 - 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
You embed
tracker.jsonce 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.phpserves the exact same script — it justreadfile()stracker.jsand re-sends it with PHP-controlled headers, since some hosts misconfigure static.jsMIME types in a way that makes a cross-origin<script>load get silently blocked (CORB). Admin → Settings gives you thetracker.jssnippet above by default; if it silently fails to load on your storefront, swap intracker.phpinstead — same script, PHP-served. Only include one of the two on a given page, not both.When someone visits with
?via=THEIRCODEin the URL, the snippet POSTs toclick.php, which logs the click and returns aclick_uid. The snippet stores it in arefreal_viacookie (lifetime = Cookie Expiry, set in Settings) — on the domain the visitor is actually browsing (your storefront), not Refreal's own domain.At checkout, that
click_uidhas 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.jsdoes this automatically. It finds anypolar.shorbuy.stripe.comlink on the page and rewrites itshrefto 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_viacookie from the incoming request and sets it asmetadata.refreal_click(Polar) orclient_reference_id/metadata.refreal_click(Stripe) when it calls the Checkout API. - Custom checkout:
click_uidfield in the POST body towebhooks/generic.php
A checkout link on a domain
tracker.jsdoesn't recognize can still be tagged automatically — add adata-refreal-param="reference_id"(or whatever query param that provider expects) attribute to the link, ordata-refreal-checkoutas a shorthand forreference_id.- 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):
When the provider webhook fires, Refreal looks up the click, resolves the affiliate, and compares
conversion_date - click_dateagainst 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.
Referral links point at your store, not at Refreal
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
.htaccessrewrites clean URLs to their real.phpfile internally (no visible redirect), and separately redirects anyone who still hits an old.phplink to the clean version — exceptwebhooks/*.phpandclick.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). Requiresmod_rewriteenabled andAllowOverride Allfor the app's directory — see Deployment. - nginx or the PHP built-in dev server — neither reads
.htaccess. For nginx, add the equivalentlocation/try_filesblock from Deployment. For local dev, runphp -S localhost:8000 router.php— the bundledrouter.phpemulates 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:
| Sent to | Triggered by | Sent when | |
|---|---|---|---|
Welcome / application receivedrefreal_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 alertrefreal_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 approvedrefreal_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 sentrefreal_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-inmail(), 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 overfsockopen()(refreal_send_via_smtp()), supporting STARTTLS/SSL andAUTH 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
activeimmediately. - 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.phpshows a "signups are closed" message) and Require manual approval (when on, new signups land asstatus = 'pending'and don't get a working referral link until an admin approves them from Affiliates → Pending; when off, they'reactiveimmediately).
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_rateis a percentage (e.g.25= 25%). Commission =amount_cents * rate / 100, rounded to the nearest cent. - Fixed affiliates:
commission_rateis 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-checkoutattribute, sotracker.jshad no way to know to tag it — or a custom backend integration isn't passingmetadata.refreal_click/client_reference_id/click_uidat all, or - The visitor's cookie was cleared/blocked before checkout, or
- The
ref_codesent doesn't match any affiliate'sreferral_codeexactly (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'sportal_token(found/regenerated on Admin → Affiliates → Edit).- Unknown/invalid token →
404with a plain "link not found" page. - Regenerating the token from the admin panel immediately invalidates the old URL.
- If the affiliate's
statusispending, 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
- Polar Dashboard → your organization → Webhooks → Add Endpoint.
- URL:
https://yourdomain.com/webhooks/polar.php - Events: enable order.paid and order.refunded (that's all Refreal listens for; other events are safely ignored).
- 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:
- Check Admin → Overview — a new row should appear under Recent
Conversions, tagged
Polar. - 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 renderedhref, 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
- Stripe Dashboard → Developers → Webhooks → Add endpoint.
- URL:
https://yourdomain.com/webhooks/stripe.php - Events to send: checkout.session.completed and charge.refunded.
- 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'sstripe_webhook_secretsetting 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>
2. Read the cookie at checkout time
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_centsas 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 sameexternal_idwithout creating duplicate conversions.
Deployment
Requirements recap
- PHP 8.1+ with
pdo_sqliteandsqlite3extensions (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.)
- Upload the
refreal/folder into your webroot (or a subdomain liketrack.yourdomain.compointed at its own folder). - Ensure
data/is writable —chmod 775 datavia file manager or SSH. - Most shared hosts serve
.htaccessautomatically (Apache). The included.htaccessblocks direct access toincludes/. 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. - Visit
/admin/login.phpto 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
.htaccessequivalents..htaccessonly works under Apache. If you're on nginx, you must add thedeny allblocks fordata/andincludes/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.txtafter 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_originsdefaults to*soclick.phpandwebhooks/generic.phpwork 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.phpor 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. nginxlimit_req, Cloudflare rate limiting rules). - Backups. See Deployment → Backups. A
compromised or corrupted
data/refreal.sqlitewith 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 inwebhooks/generic.phpyou'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_centsfor conversions inpending/approved/paidstatus (refunded and expired/unattributed sales excluded). - Unpaid Balance — sum of
commission_centsforpending+approvedconversions — 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
activeimmediately — 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_idset toNULL), 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:
- 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).
- 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. - 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 fortracker.js. - 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. - 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
approvedorpaidconversions 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
pendingrows toapproved. Only pending/approved rows show a checkbox. - Checkbox + Mark Paid — moves checked
approvedrows topaid, grouping them into onepayoutsrecord 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
- Open Affiliates → Pending and clear out any new applications (approve or reject).
- Open Payouts, filter to Pending.
- Review the list — spot-check any
unattributed/expiredspikes (tab over to those filters) since a sudden increase usually means an integration broke. - Select the legitimate pending rows → Approve Selected.
- Switch to the Approved tab, select the batch you're ready to pay → Mark Paid, choosing method + note. Affected affiliates get an email automatically.
- 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).
- 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 affiliate portal link says "Link not found".
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.
I moved the app to a subdirectory and links broke.
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.
I approved a self-serve affiliate but they say their link doesn't work.
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.