how to design a referral system

three scenes — a front desk, a guest with a phone, a stage — joined by one line

A booking business grows through people who send it guests: hotel concierges, tour desks, bar owners with a sticker on the wall, collaborators with a following. A referral system is the machinery that pays those people correctly. It has to attribute a booking to the partner who caused it, optionally discount the guest, record what the partner earned, and settle it — and every one of those steps moves money or trust, so each one needs an exact rule.

The design below is the one running in production for a venue that sells ticketed dinner shows, at a scale of several hundred active partner links. The prose stands on its own; the sequence diagrams are the engineer's view of the same sentences, there for readers who want the wire-level truth. It is worth studying because it is small: six tables, one public redirect, one validator, one payment hook, one payout batch — and a long list of small decisions that each prevent a specific way this system goes wrong.

run the live demo — every rule in this article, in your browser →

the parties — three stories

Three people touch this system, and each one sees a different tenth of it. Read each story start to finish — the machinery in the rest of the article exists to keep all three true at once.

the concierge

the partner — a hotel front desk

Mai runs the front desk of a seaside hotel. Twice a week a guest asks what to do tonight, and for years her answer was a bar name and a shrug — good will, no record, no reward. The venue signs her hotel up as a partner: one row in a table, a contact name, a type, a public slug. Nothing about her job changes.

A week later a small envelope arrives: qr stickers for the front desk and cards for the room menus, each printed with the venue's art and her hotel's own short link — /go/seaside. When a guest asks tonight's question, she points at the sticker instead of shrugging. The guest scans, books, and gets a small discount for taking her advice; the booking silently carries her hotel's name. She never types anything, never keeps a list, never wonders whether the venue is counting fairly.

At month's end she opens her profile page and sees exactly what she sent and what she earned — this period's paid bookings, the two still awaiting approval, the total coming to her. No spreadsheet from the venue, no trusting anyone's memory. And because her short link never changes, the sticker she printed in march still works in september.

the concierge — the partner — a hotel front desk
partner profile — the concierge's own page

seaside hotel & spa

hotel partner · front desk

active
your code
HOL_SEASIDE_HOTEL_SPA_X7K2P9
commission
5% of paid bookings
guest discount
5% applied automatically

your links

  • front-desk qr stickerhouseoflegends.vn/go/seaside
  • in-room menu cardhouseoflegends.vn/go/seaside-room
earned this period2,180,000

14 paid bookings · 2 awaiting approval

the traveler

the guest — at a bar, on a phone

A traveler finishes a beer, spots a qr sticker on the table, and scans it. The phone lands on the booking flow already attributed — no code to type, no "where did you hear about us" dropdown to lie to, no awkward "mention this card" at the door. The sticker's short link did the remembering.

The price panel shows one honest line: a referral discount, applied automatically, subtracted before the total. Had they instead typed a code a concierge whispered them, the same validator would have run behind the same line. Either way the guest never learns that a commission rides on their booking — the interface's only job here is to not charge them extra for being referred, and to not make them do paperwork about it.

If they close the tab and come back on thursday, the attribution is still there — thirty days, their browser, their business. The system treats their click as theirs to keep, not as a tracking event to shout about.

the traveler — the guest — at a bar, on a phone
booking summary — the guest's price panel

saturday dinner show

2 tickets · 7:30 pm

tickets
1,960,000
subtotal
1,960,000
referral discount
−98,000
total1,862,000

5% referral discount applied — from your hotel's qr sticker, no code typed

the operator

the venue — settling the ledger

The operator's side of the deal starts before any of this: negotiate a commission — five percent of what the guest actually pays — decide whether the code carries a guest discount, cap it, window it, restrict it to certain shows if the deal says so. That configuration is the whole contract; everything downstream reads it and never re-imagines it.

On the first of the month the operator opens the referrals workspace. Every paid booking that arrived through a partner is already a commission row — earned when the payment landed, not when the booking was made, so the cancelled table for two never polluted the numbers, and the refund that arrived after settlement shows as its own clawed-back row instead of a hole. They approve the month's rows, select a partner, and create one payout batch: the endpoint verifies every row belongs to that partner and is approved, sums it, records the bank transfer reference, and flips the rows to paid.

When a partner disputes a missing booking, the dispute is one query — the ledger row, the link slug, the reservation reference — never an archaeology through emails. That is the entire point of the design: the operator can prove the ledger true without believing anyone.

the operator — the venue — settling the ledger
commissions — the operator's ledger
reservationpartnerpaidcommissionstatus
RSV_260812_0007seaside hotel & spa1,862,00093,100approved
RSV_260815_001250decibel sports bar2,420,000121,000pending
RSV_260818_0003seaside hotel & spa980,00049,000paid
RSV_260802_0021sunrise concierge1,120,00056,000clawed-back

august payout — seaside hotel & spa

22 commissions · bank transfer ref TRF-0831-SEASIDE

3,420,000

paid
the three parties at a glance — same system, three different views
partywantsseestouchesnever sees
conciergecredit for guests they sendtheir profile: links, bookings, earningsa sticker, a short link, a profile pagethe ledger, other partners, guest details
travelera good night out, no frictiona booking flow and one discount linea qr sticker, their phonethat a commission exists at all
operatora ledger that proves itselfcommissions, payouts, disputes as queriescode config, approvals, payout batchesa reason to trust memory

the five decisions

Before any schema, decide these. Every referral design is these five answers; the rest is implementation.

  1. who attributes. a guest either types a code at checkout, or arrives through a link that carries the attribution invisibly. you need both: codes for humans, links for stickers and urls.
  2. what the guest sees. a code may apply a discount, or be purely an attribution marker with no guest-visible effect. keep that a flag on the code, not a separate concept.
  3. what the partner earns. percentage or fixed amount, with an optional cap, measured against one basis — the final paid amount, and nothing else. per-show rates are a conversation to have when a partner asks, not a column to pre-build.
  4. when money is earned. when the reservation is paid, not when it is booked. a booking can be cancelled; a paid amount cannot silently reverse. earning on payment makes the ledger truthful by construction.
  5. how it settles. commissions accrue as pending, an operator approves them, approved commissions are batched into a payout per partner per period. manual approval is the fraud brake; keep a human on it until the numbers are boring.

the model

Six entities, each owning one question. Anything referral-shaped that is not one of these six is a status value or a column, not a new table.

the six entities — what each one owns
entityownsthe shape of it
partnerwho sends guestscontact fields, a type, a public slug, status: active / paused / archived. pausing a partner disables every code and link under it without touching them
codethe dealguest-discount terms, commission terms, validity window, usage cap with counter, optional show and booking-path allowlists, status: active / paused / expired / archived
linkthe distribution surfacepublic slug, destination path, channel (qr sticker, hotel menu, bar, influencer, concierge), label, status. one code fans out to many separately measurable links
attribution eventthe click recordper link visit: link, code, session and visitor ids, landing path, hashed ip and user agent, an expiry. evidence layer; never touches money
commissionthe money ledgerone row per paid reservation; snapshots the deal at earn time next to the paid amount and the computed commission. status: pending / approved / payout-pending (chosen for a batch, transfer not yet sent) / paid / cancelled / clawed-back
payoutthe settlement batchapproved commissions for one partner over a period, one transfer, a method and reference, status: draft / approved / paid / cancelled

The reservations table joins this graph at three points — the code id, the partner id, and the link id — plus a snapshot of the discount amount applied. Those three ids are the attribution; everything else is derived by joining back. Index all three, because payout and reporting queries filter on them.

Distribution is a short url: /go/hotel-menu on the main domain. The slug — the friendly name a human could read out loud — is the only thing the partner ever prints or shares. The managed code behind it never appears in a guest url — codes are long generated strings meant for operators, and leaking them into urls means guests strip them, edit them, and share them sideways, at which point attribution data is noise.

a stack of qr stickers on a bar counter, one lifted by an unseen hand under a spotlight
sequence — resolving a /go slug into attribution

rendering diagram…

The small details that matter:

  • the join resolves link → code → partner in one query, and the resolver returns nothing unless all three rows are active. a paused partner kills its links instantly, with no link cleanup job.
  • the redirect is 307 — "the thing you want has moved here, for now" — never 301, "moved here forever". a forever-redirect gets memorized by browsers and cdns, and a sticker printed with a dead destination is unrecoverable.
  • the destination path is stored per link and localized on the way out: the route rewrites it into the guest's locale segment so a qr code on a vietnamese menu lands on the vietnamese booking flow. the default locale serves printed material, which is english.
  • the attribution event records session and anonymous visitor ids, the landing path, the referring url, and hashed ip and user agent — the visitor's browser and network scrambled into fingerprints that cannot be turned back into a person, because this table is analytics, not security, and raw ips in a marketing table are a liability with no use case.
  • the response sets a first-party cookie — set by the venue's own site, not a third party — carrying the attribution as validated json: code id, partner id, link id, channel, and the guest-discount terms if any. it lives thirty days and is deliberately readable by the page itself, so a booking flow that is already open re-reads it, keeps a copy in local storage, and announces the change mid-visit. no other site and no ad network can read it.
  • the payload carries an expiry and is parsed with a strict schema on every read; a malformed or expired value reads as no attribution, never as a crash or a fallback guess.

process 2 — validating a code at estimate time

The booking page prices the cart through one estimate endpoint, and that endpoint owns the referral check. Whether the code came from the hidden attribution or a guest typing it into a field, the same validator runs, and the client only ever renders the verdict — never the arithmetic.

a mechanical gate that tokens pass through; one accepted with an emerald check, one bounced off rejected
sequence — the estimate call validates the code and returns the discounted totals

rendering diagram…

The validator's checks, in the order they run — order matters, because each failure returns its own exact reason and the guest sees that reason:

  • normalize first: trim and uppercase. guests type lowercase, with spaces, on phones.
  • not found is a reason. not active is a different reason. "not active yet" (before the window opens) and "expired" are different again. an exact reason lets the interface say what to do, not just "invalid code".
  • the usage cap is checked as used ≥ max. the cap is enforced again later, atomically, at payment — this check is for the guest's error message, not for correctness. correctness is never read-then-write — look at the counter in one step, update it in another, and a concurrent booking slips through the gap between the two.
  • show and booking-path allowlists apply only when set: a code restricted to one show or one booking type says so, by name of the restriction it failed.
  • the discount math: percentage or fixed; skipped below the minimum booking value; capped at the configured maximum; and clamped to the subtotal so a misconfigured fixed discount can never go negative or exceed the cart.
  • the referral discount applies after any promo discount, on the post-promo subtotal — so a 10% promo and a 5% referral discount take 14.5% off together, not 15%. the order is a decision, not arithmetic fate; write it down, because someone will ask.
the two attribution roads — one deal, two ways in
typed codehidden link
who uses ita guest who heard the code from a personanyone who scans a sticker or taps a shared url
what the guest seesa code field and a status messagenothing — attribution rides the redirect
what carries itthe code string in the requesta first-party cookie set by the /go redirect, 30 days
discountsame validator, same mathsame validator, same math
if both are presentthe typed code wins — the guest said sothe cookie is kept only when it points at the very same deal the guest typed; mixed halves are discarded
failure modean exact reason shown to the guestsilence: no attribution, booking proceeds normally

process 3 — creating the attributed reservation

The create request carries two optional referral fields: the code string, and the hidden link id from the attribution. The server does three things the client is not trusted to do:

an admission ticket stamped with a glowing seal sliding into a card catalog drawer
the reservation row is the stamp: three ids and a discount, written once, read forever

rendering diagram…

  • it re-runs the full validator against the real cart. the estimate the guest saw is advisory; this is the recorded price.
  • it resolves the link id only if that link is active and belongs to the same code being applied. an attribution cookie from an old campaign paired with a freshly typed code does not get to mix halves.
  • it stores the resolution as three ids plus the discount amount on the reservation row. the reservation is then self-describing for every later step — payment, commission, reporting — with no re-derivation from cookies.

If a typed code and a hidden attribution are both present, the typed code wins and the hidden link is dropped unless it matches that code. The guest's explicit action outranks the cookie.

process 4 — earning commission on payment

This is the step where the design earns its keep. The payment provider calls back when a payment completes, and its reconciliation job — the provider's own double-check — calls again about the same payment. Both arrive. In plain words: the same payment must never be counted twice, and the guarantee lives in one database statement, not in coordination machinery between servers (what engineers call distributed locks).

coins flowing from a payment terminal, most into a theatre tray, a few diverted into a small ledger book
sequence — the payment hook; one transaction, four writes, one guard

rendering diagram…

The details, one by one:

  • the once-only gate is the conditional update itself: set status = PAID where status != PAID. if it updates zero rows, a concurrent callback already transitioned the reservation — acknowledge and stop. no locks, no deduplication table, no idempotency key plumbing for this path.
  • the code's usage counter increments inside the same transaction with a conditional update — only if used is still below max. a code that hit its cap between estimate and payment still completes the payment; the counter simply refuses to lie.
  • the commission row is computed from the code's terms as they are now, applied to the paid amount: percentage or fixed, capped at the configured maximum, clamped to the paid amount, rounded to an integer. the terms are then copied onto the row. the ledger snapshots the deal, so later edits to the code never rewrite history.
  • one commission row per reservation, enforced by the database itself — a unique index that silently refuses a second row for the same reservation. if the math or the config yields zero — no terms on the code, a zero paid amount — no row is written. attribution without a deal is legitimate; it is still a partner's guest.
  • everything above is one database transaction. a payment recorded without its commission, or a counter incremented without a payment, is a reconciliation bug that costs an evening to find.

process 5 — cancellations and clawbacks

Money that was earned can stop being earned. Two distinct paths, because they start from different states:

a hand lifting one coin back out of a tray, leaving a hollow outline where it sat
sequence — a cancellation voids unsettled commissions; a refund claws back settled ones

rendering diagram…

cancel vs clawback — two reversals, two different starting states
cancellationclawback
what happenedthe reservation was cancelled before settlementthe payment was refunded after the commission settled
commissions affectedpending, approved, payout-pendingpaid
they becomecancelled, with a timestampclawed-back, with a timestamp
usage counterdecremented, floored at zerountouched — the booking did happen
the ledger rowkept, marked — never deletedkept, marked — never deleted
next payoutnever included — batches only take approved rowsrecovered by netting: the clawed-back amount is deducted from the partner's next batch and noted on the payout record
  • cancelling voids every unsettled state in one statement — pending, approved, payout-pending — because an approved-but-unpaid commission is still reversible money.
  • the usage counter decrements with a floor at zero. a cancel of a booking that never incremented (the capped case above) must not produce a negative counter that re-opens a dead code.
  • a refund after settlement never deletes the paid row; it transitions it to clawed-back with a timestamp. recovery is mechanical: clawed-back rows are never eligible for a batch, so the operator nets the amount against the partner's next payout and records the offset on that payout. the ledger stays append-shaped: every amount the system ever believed is still queryable, which is what makes a partner dispute resolvable in one lookup instead of an audit.

process 6 — approving and paying out

Settlement is a batch operation with a verify-everything-first rule. The operator picks a partner and a set of commission ids; the endpoint either pays all of them or none of them.

a sealed envelope with a wax seal handed across a desk, coins stacked beside it
sequence — the payout batch: verify, sum, write, flip, in one transaction

rendering diagram…

  • the batch counts the selected rows and requires the count to equal the request length. one id that is missing, belongs to a different partner, or sits in the wrong state rejects the entire batch — partial payouts teach operators to distrust the screen.
  • the payout row is created first, then the commissions take its id and their new status in the same transaction. a commission points at its payout or the payout never existed; both directions stay true. the batch is either created as paid in one motion, or as a draft that flips to paid when the transfer lands — in between, its commissions sit at payout-pending, chosen for a batch with the money not yet sent.
  • creating the batch as paid also stamps the per-commission paid-at and the transfer's method and external reference. bank-transfer references live here — this row is the proof of settlement when a partner asks where their money is.

the operator surface

The admin side is one workspace with status tabs — all, active, paused, expired, archived — because status is the only axis an operator actually works along: activate what was negotiated, pause what is disputed, archive what is dead. Beside it:

  • creating a code inline-creates its partner when the partner does not exist yet — the two are born together in real work, and a code cannot exist without one.
  • an active code must carry complete commission terms; the create and update paths refuse an active code with no commission type or a non-positive value. an incomplete deal cannot go live by accident.
  • each link can upload its qr sticker artwork, stored per-link so a hotel menu and a bar sticker print differently while resolving to the same code.
  • a csv export of code usage over a date range, filterable by code, joins the reservation references — the artifact a partner actually wants to see during a dispute.
  • a public partner directory and per-partner profile pages on the guest site, fed by the same data, so a hotel listed as a partner is also a listing the venue owes a correct profile.

the rulings

The decisions worth copying into any system that attributes and pays:

  • hide managed codes behind public slugs. printed and shared urls carry a friendly slug; the operator-grade code never enters a guest-visible url.
  • the server owns every number. the client sends intent — a code string, a link id — and renders verdicts. totals, discounts, and commissions are computed server-side at estimate, again at creation, and again at payment, from live rows.
  • earn on payment, not booking. the ledger records reality: money that changed hands.
  • snapshot the deal at earn time. the commission row copies the terms it was computed from. renegotiating a code next quarter does not rewrite last quarter's ledger.
  • make the money hook idempotent by construction. a conditional state transition is the gate; everything else in the transaction follows it.
  • one commission row per reservation. a unique index, not a discipline.
  • caps are conditional updates, never read-then-write. the check that produces an error message and the update that enforces the cap are different operations; only the second one is correctness.
  • statuses are machines with timestamps. every transition — cancelled-at, approved-at, paid-at — is a column, so any row can explain itself.
  • the ledger never deletes. reversals are clawed-back rows, not removed ones. an audit is a query, not an archaeology.

search pages

go to any page