how to design a referral system

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 deskMai 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.

seaside hotel & spa
hotel partner · front desk
- 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
14 paid bookings · 2 awaiting approval
the traveler
the guest — at a bar, on a phoneA 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.

saturday dinner show
2 tickets · 7:30 pm
- tickets
- 1,960,000
- subtotal
- 1,960,000
- referral discount
- −98,000
5% referral discount applied — from your hotel's qr sticker, no code typed
the operator
the venue — settling the ledgerThe 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.

| reservation | partner | paid | commission | status |
|---|---|---|---|---|
| RSV_260812_0007 | seaside hotel & spa | 1,862,000 | 93,100 | approved |
| RSV_260815_0012 | 50decibel sports bar | 2,420,000 | 121,000 | pending |
| RSV_260818_0003 | seaside hotel & spa | 980,000 | 49,000 | paid |
| RSV_260802_0021 | sunrise concierge | 1,120,000 | 56,000 | clawed-back |
august payout — seaside hotel & spa
22 commissions · bank transfer ref TRF-0831-SEASIDE
3,420,000
paid| party | wants | sees | touches | never sees |
|---|---|---|---|---|
| concierge | credit for guests they send | their profile: links, bookings, earnings | a sticker, a short link, a profile page | the ledger, other partners, guest details |
| traveler | a good night out, no friction | a booking flow and one discount line | a qr sticker, their phone | that a commission exists at all |
| operator | a ledger that proves itself | commissions, payouts, disputes as queries | code config, approvals, payout batches | a reason to trust memory |
the five decisions
Before any schema, decide these. Every referral design is these five answers; the rest is implementation.
- 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.
- 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.
- 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.
- 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.
- 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.
| entity | owns | the shape of it |
|---|---|---|
| partner | who sends guests | contact fields, a type, a public slug, status: active / paused / archived. pausing a partner disables every code and link under it without touching them |
| code | the deal | guest-discount terms, commission terms, validity window, usage cap with counter, optional show and booking-path allowlists, status: active / paused / expired / archived |
| link | the distribution surface | public slug, destination path, channel (qr sticker, hotel menu, bar, influencer, concierge), label, status. one code fans out to many separately measurable links |
| attribution event | the click record | per link visit: link, code, session and visitor ids, landing path, hashed ip and user agent, an expiry. evidence layer; never touches money |
| commission | the money ledger | one 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 |
| payout | the settlement batch | approved 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.
process 1 — a partner link visit
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.

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.

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.
| typed code | hidden link | |
|---|---|---|
| who uses it | a guest who heard the code from a person | anyone who scans a sticker or taps a shared url |
| what the guest sees | a code field and a status message | nothing — attribution rides the redirect |
| what carries it | the code string in the request | a first-party cookie set by the /go redirect, 30 days |
| discount | same validator, same math | same validator, same math |
| if both are present | the typed code wins — the guest said so | the cookie is kept only when it points at the very same deal the guest typed; mixed halves are discarded |
| failure mode | an exact reason shown to the guest | silence: 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:

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).

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:

rendering diagram…
| cancellation | clawback | |
|---|---|---|
| what happened | the reservation was cancelled before settlement | the payment was refunded after the commission settled |
| commissions affected | pending, approved, payout-pending | paid |
| they become | cancelled, with a timestamp | clawed-back, with a timestamp |
| usage counter | decremented, floored at zero | untouched — the booking did happen |
| the ledger row | kept, marked — never deleted | kept, marked — never deleted |
| next payout | never included — batches only take approved rows | recovered 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.

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.