Referral Program #
Classification: Internal-Only
Customers who refer other businesses to Tofu earn a recurring discount for as long as the referred business stays subscribed. This document explains the mechanics, the state involved, and the edge cases — which is most of the interesting part, because the flow spans four systems and several things can go sideways between “referrer clicks send” and “discount appears on an invoice”.
How the reward works #
- A referrer earns 5% recurring discount per qualified referee, stacking, capped at 50% — ten referrals.
- A referee qualifies on their first successful payment plus a further 30 days subscribed.
The window is configured per environment (
REFERRAL_QUALIFY_AFTER_IN_MILLISECS), so check the value before promising a customer a date. - The referrer’s coupon is open-ended: while it is attached to their Stripe customer it discounts every invoice, with no end date. What is not fixed is which coupon is attached. The tier is recomputed as referees qualify and as they cancel, and the coupon is swapped to match — so the percentage can go down as well as up, and revocation removes it entirely. Nothing about a referral is banked once earned.
- The referee’s coupon is one-off: it applies to their first payment and is consumed there. 5%, or 10% if their referrer is at the cap at the moment the coupon is attached. A referee at 10% rather than 5% is one better invoice, not an ongoing better deal.
- Both are Stripe percentage coupons on the customer:
referrer_coupon_5…referrer_coupon_50andreferee_coupon_5/referee_coupon_10. A customer holds one discount at a time, so a tier change swaps the coupon rather than adding a second one — which is also why referral coupons collide with any other discount on the same customer. - The tier is derived from a live count of qualified referrals whose referrer has not lapsed — it is never stored. There is no tier column to inspect or correct. If a referrer’s arithmetic disagrees with their invoice, count their qualified referrals; that count is the answer by definition.
How the referee’s discount actually gets attached #
The coupon goes on the referee’s Stripe customer, so it cannot be attached until that customer exists.
organization.created creates the customer and attaches the coupon, in that order, as soon as the
referee makes their organization — well before they reach checkout, so it lands on the first invoice.
Three properties of that path are worth knowing:
- It is gated on
AWAITING_PAYMENT. That gate is the idempotency mechanism, and it is why the attach must come after the referral commits: there has to be a row for the gate to find. Once the first payment lands the referral leavesAWAITING_PAYMENTand the coupon is never touched again. - The percentage is read at attach time, not at referral creation. It comes from the referrer’s tier as it stands when the coupon is attached, so a referrer who crosses the cap in between gives their referee 10% rather than 5%.
- It is best-effort. A failure is logged and swallowed rather than failing the webhook, which Clerk would
otherwise redeliver. A second chance comes from
organization.updated, which re-attempts the attach with the same gate; a transient failure therefore usually self-heals, but only while the referral is stillAWAITING_PAYMENT.
That last point is the failure mode: if the coupon never attaches before the referee pays, the gate closes and they pay full price with no way for the system to correct it. See the referee’s discount never arrived.
Statuses #
AWAITING_PAYMENT → AWAITING_QUALIFICATION → QUALIFIED, with SUBSCRIPTION_CANCELLED reachable
from any of them. A referee cancelling moves the row out of QUALIFIED, which is what makes the tier
fall.
INVITATION_ENDED is the fifth value, and it exists to keep two very different endings apart. A
referral that ended because the invitation never got taken up — withdrawn, declined, or the referee
arrived by another route — has nothing in common with one that ended because a subscription stopped.
Collapsing both into SUBSCRIPTION_CANCELLED would leave an invitation nobody accepted and a customer who
churned at day 20 indistinguishable in the UI, and every reader reconstructing which had happened from the
presence of other columns. One extra value removes that guesswork.
No state is permanently terminal. SUBSCRIPTION_CANCELLED is where a lapsed referral rests, not
where it dies: a referee who pays again restarts the countdown from the new payment.
qualified_at is kept when that happens, as history. A row carrying both qualified_at and
referee_subscription_cancelled_at is one that earned and then stopped earning, and the pair is how
that is told apart from one that never qualified.
Because a cancelled referral leaves QUALIFIED, the tier definition needs no extra condition: a live
count of QUALIFIED rows with referrer_lapsed_at IS NULL already excludes them.
This document uses the stored spelling — AWAITING_PAYMENT — which is what the Postgres enum, the
API and any SQL you write will contain. Tickets and Rust code use the variant name AwaitingPayment;
the sqlx type derives SCREAMING_SNAKE_CASE, so the two are the same value. Only the stored form is
safe to use as a string literal.
There is no Invited status, and no Expired one. “Invited but not yet accepted” is
accepted_at IS NULL AND status = AWAITING_PAYMENT, and expiry is last_sent_at plus Clerk’s
invitation lifetime — both derived when read, so neither can go stale. The referrer-facing table shows
both states; see
What the referral history table shows.
Status does not tell you whether the referee has an organization yet. The referee creates their own, so
referral.referee is nullable and a row can sit in AWAITING_PAYMENT with
accepted_at set and referee IS NULL — signed up, no organization. That is a normal waypoint, not a
status of its own: it needs no distinct display and it resolves itself the moment they create one. Any query
joining referral to organization must therefore tolerate a missing referee rather than assume the join
succeeds. See
the referee accepts but never creates an organization.
The flow #
The referee is invited to **Tofu itself**, not to an organization. `referrer_code` rides on that invitation and lands on the **user's** `publicMetadata`, which is the only thing tying a new organization back to its referral. One operational dependency: **`user.created` must be subscribed in the Clerk dashboard.** It is the acceptance signal, and nothing else records consent — if it is not delivered, `accepted_at` stays null for every referee while the rest of the flow works normally.
graph TB
A[Referrer enters referee email in Tofu]
B[Validation: parse the address, duplicate,
daily cap, existing customer]
C[Write the referral row
AWAITING_PAYMENT, referee NULL]
D[Create Clerk instance invitation
publicMetadata: referrer_code]
E[Clerk invitation page: choose login method,
tick T&C consent, Clerk creates the user
carrying referrer_code]
F[Clerk user.created webhook
stamp referral.accepted_at]
G[Referee creates their own organization
name, country, the usual onboarding]
H[Clerk organization.created webhook
link referral.referee, trial metadata,
Stripe customer, referee coupon, Intercom]
I[Stripe payment_succeeded
AWAITING_QUALIFICATION, 30-day clock starts]
J[Daily cron at day 30
QUALIFIED, coupon created or swapped]
K[Coupon-earned email to referrer]
A --> B --> C --> D --> E
E -.->|async, off the critical path| F
E --> G --> H --> I --> J --> K
The referral row is written before the invitation is sent. An invitation that exists without a referral row means a referee can sign up and earn the referrer nothing, and that failure is silent. A referral row with no invitation is visible and resendable.
Acceptance and the consent record #
accepted_at is stamped by a webhook, not by anything on the request path — the referee accepts inside Clerk,
so Clerk telling us is the only signal.
The signal is user.created. An instance invitation has no organization to accept into, so
organizationInvitation.accepted never fires. The event that does arrive says the referee accepted and Clerk
made them an account carrying referrer_code in their publicMetadata.
`user.created` is modelled in `ClerkEvent` and handled, but it also has to be **subscribed in the Clerk dashboard** to arrive at all. `ClerkEvent` has no catch-all variant, so a subscription and its variant have to move together, in that order: subscribing to an event nothing models fails deserialization for every delivery on the endpoint, not just that one.
Correlation is by code and address. There is no invitation id to match on, and no referee organization
yet, so the key is the referrer_code on the new user plus the address they signed up with, matched
against referral.referee_email for that referrer. Clerk invitations are address-bound, so the address is the
same one the referrer typed — and that is the reason referee_email is NOT NULL.
Two consequences of this being a webhook: it is asynchronous, so there is a window
where the referee has an account while the referral still reads as un-accepted; and it is not on the critical
path — a missed event leaves accepted_at null without blocking signup, payment or qualification.
It is the only writer of accepted_at, which is the only thing this row says about consent — so treat a gap
in these events as a data problem worth reconciling against Clerk rather than a display glitch.
Linking the organization when the referee makes one #
referral.referee points at organization, and there is no organization at invite time — so it is
nullable, and is filled in when organization.created arrives for a user carrying referrer_code.
UNIQUE(referee) holds as uniqueness among non-null values.
That handler is where everything organization-shaped happens: link the referral, write the trial metadata, create the Stripe customer, attach the referee coupon, create the Intercom company.
First organization wins. The rule is that the first one links and later ones do not, which falls out of the
row already having a non-null referee: nothing else matches. Without that rule one invitation could yield two
referrals and pay the referrer twice.
There is a state between acceptance and the organization: the referee has an account and no organization. The history table has to render it — see what the history table shows.
Who writes what #
One writer per fact. The referee’s organization row has exactly one writer, the organization.created /
organization.updated webhook. The send endpoint creates no organization, so there is nothing for it to race
with: the referee creates the organization, and the webhook is the only thing that records it.
That is worth stating because the alternative is tempting and broken. An endpoint that provisioned the
organization up front would be racing Clerk’s own webhook, which fires the moment the organization exists. The
loser of that race takes a duplicate-key violation on organization.external_id — and when the loser is the
endpoint, the request fails and no invitation is ever sent, leaving an organization row nobody asked for.
The ordering rule inside the endpoint:
The referral is committed before the invitation is created. This is what makes “the referee signed up but no referral exists” impossible rather than merely recoverable — an invitation cannot exist unless the referral already does. The opposite order is the worse trade: that failure is silent, and it costs the referrer a discount they earned. The cost of this order is that a failed send leaves a referral with no invitation; a repeat send is refused, and re-sending is a resend.
What the endpoint does, in full: validate, write the referral row with referee null, create the Clerk
instance invitation carrying referrer_code. No organization, no Stripe customer, no Intercom company, no
trial metadata — all of that belongs to organization.created, and none of it exists until the referee has
consented and made an organization of their own.
Billing and CRM are best-effort. The referee’s coupon is gated on AWAITING_PAYMENT and runs from
organization.created alongside the Stripe customer. None of it may fail the webhook: the referral is already
committed, and organization.updated re-attempts all of it later with the same gates.
Cancellation and revocation #
A referral can end five ways, and they divide cleanly by what ended.
| Route | Trigger | Lands on |
|---|---|---|
| Referee cancels | customer.subscription.deleted |
SUBSCRIPTION_CANCELLED |
| Referrer withdraws the invitation | Explicit action in Tofu | INVITATION_ENDED, reason WITHDRAWN_BY_REFERRER |
| Referee signs up without any invitation | user.created carrying no referrer_code |
INVITATION_ENDED, reason SIGNED_UP_INDEPENDENTLY |
| Referee signs up on another referrer’s invitation | user.created carrying a different referrer_code |
INVITATION_ENDED, reason SIGNED_UP_INDEPENDENTLY |
| Referee’s organization is deleted before it ever paid | organization.deleted for a referral tracking no subscription |
SUBSCRIPTION_CANCELLED |
Deleting the organization of a referee who has paid is deliberately not one of them — the subscription bills on. See referee organization is deleted.
Separately, referrer_lapsed_at takes a referrer’s referrals out of the tier count without moving
their status at all.
The subscription routes are what move money, so they are the ones drawn below. The invitation routes end a referral that never earned anything.
customer.subscription.deleted covers a subscription Stripe deletes itself after dunning fails, with
no separate handling — see
the referee’s payment fails inside the qualification window.
graph TB
A[Referee cancels subscription
Stripe webhook] --> B{Had it qualified?}
B -->|No: AWAITING_PAYMENT
AWAITING_QUALIFICATION| C[SUBSCRIPTION_CANCELLED
countdown stops, never counted]
B -->|Yes: QUALIFIED| D[SUBSCRIPTION_CANCELLED
qualified_at kept as history]
D --> E[Recalculate referrer tier
swap coupon down 5%]
I[Referrer cancels own subscription
Stripe webhook] --> J[Stamp referrer_lapsed_at on ALL
their referral rows]
J --> K[Strip referral coupon
permanent, climbs from zero again]
Referee cancels. Driven by customer.subscription.deleted — which covers both a voluntary
cancellation and a subscription Stripe deletes after dunning fails, deliberately with no separate
handling for the latter. Before qualification it stops the countdown and the referral never counted.
After qualification it moves the row out of QUALIFIED, so the referrer’s tier falls by that 5% and
their coupon is swapped down. qualified_at is kept either way, and the row can later revive if the
referee pays again.
Reaching tier zero removes the coupon rather than leaving it. This is worth stating because the natural implementation gets it wrong: the tier-to-coupon mapping has no coupon for zero, so code that treats “no coupon” as “nothing to do” leaves the last 5% attached to a referrer who no longer has a single qualified referral. Going up that shortcut is harmless; going down it is the fraud hole itself. Only referral coupons are stripped, so a hand-negotiated discount on the same customer survives.
Only Stripe moves the tier. customer.subscription.deleted is the single trigger, and Clerk’s
organization.deleted deliberately does not touch referrals — see
referee organization is deleted.
Referrer cancels. referrer_lapsed_at is stamped on every row they own, not just the qualified
ones, and the effect is permanent — lapsed rows never count again, so a returning referrer climbs from
zero. Only known referral coupon ids are stripped from Stripe, so a hand-negotiated discount on the
same customer survives.
Revocation does not cancel a referral #
Clerk’s invitation-revoked event is recorded and otherwise ignored. The referral’s lifecycle is not derived from the invitation’s.
The reason is that the event says an invitation stopped existing without saying why, and the whys want opposite outcomes:
| Why it was revoked | Should the referral end? |
|---|---|
| The referrer withdrew it deliberately | Yes — but the withdraw action has already done it |
| It was revoked because a resend replaced it | No |
A handler acting on the event alone has to guess, and the case it gets wrong is the resend — destroying a referral at the precise moment someone is trying to help a customer. Since resend is a supported action, that is not a hypothetical.
So the things that genuinely end a referral do it explicitly, where the intent is known: the withdraw
action, or a signup that arrives without a referrer code. An invitation is a delivery artifact; the referral
tracks its own state in accepted_at, status and invitation_ended_reason.
Two things follow. Revoke-to-replace is safe — every resend revokes the invitation it replaces, and if that event ended referrals a resend would end its own. And a deliberate withdrawal records why, which a Clerk webhook could never tell us.
The alternative, writing an “expected revocation” marker before each revoke and having the handler read it, was rejected: it still fails for revocations performed in the Clerk dashboard, where no marker exists, and it is more machinery for a weaker guarantee.
Full detail on each ending, including what to check when a referrer disputes a discount, is under Edge cases.
Where state lives #
No single dashboard shows the whole picture.
| System | What it holds | Authoritative for |
|---|---|---|
YugabyteDB referral |
One row per referrer→referee pair: status, referee_email, created_by (the member who sent it), clerk_invitation_id, accepted_at, send_count, last_sent_at, payment and qualification timestamps, referrer_lapsed_at, invitation_ended_at and its reason, the referee’s Stripe subscription id, and referee — nullable until they create an organization |
Referral state |
YugabyteDB organization |
Mirror of the Clerk organization, plus referral_code, stripe_customer_id, deleted_at |
referral_code and stripe_customer_id only |
Clerk user publicMetadata |
referrer_code, carried from the invitation |
The referrer attribution |
Clerk organization publicMetadata |
org_has_setup, trial fields |
Both |
| Clerk | Invitation lifecycle | Invitation state |
| Stripe | Coupons, subscriptions, payment and cancellation events | Money |
One field looks authoritative and is not. organization is a mirror of Clerk, written by the
organization.created / organization.updated webhook. Editing it directly drifts until Clerk next fires.
Onboarding completion lives only in Clerk, as publicMetadata.org_has_setup. There is no column
mirroring it: it is client-readable, so the onboarding gate needs no backend roundtrip, and a referee cannot
reach that page without an organization of their own — so there are no unclaimed organizations for SQL
reporting to have to exclude.
What the database enforces on its own #
Five constraints, because none of them can be left to application code that might be bypassed by a migration, a script, or a second writer:
| Constraint | What it stops |
|---|---|
UNIQUE(referrer, referee_email) |
The same referrer inviting one address twice — the only double-submit guard in the send flow, which is why referee_email is NOT NULL: Postgres treats NULLs as distinct |
UNIQUE(referee) |
One referee organization carrying two referrals, which is what makes a revived referral reuse its row |
UNIQUE(clerk_invitation_id) |
Two rows naming one Clerk invitation. A resend re-points this column, so the index is also what proves each send got its own invitation |
CHECK referee_email = lower(referee_email) |
A row that the acceptance webhook could never match, since it lowercases the address Clerk sends before comparing. The repository lowercases on the way in; this is the backstop |
CHECK referrer <> referee |
An organization referring itself. Nothing in the flow can produce it — a referee’s organization is created after the invitation — so this is cheap insurance rather than a live rule |
version + WHERE version = … |
One writer silently undoing another’s. See below |
The version column is the one that guards against us, not against bad input. Every update names the
version it read — SET … version = version + 1 WHERE id = $x AND version = $y — and a write whose version
has moved is refused rather than applied.
It is needed because the two writers that matter most are not in a transaction and each writes the whole
row from its own snapshot. The qualification cron and the
cancellation webhook can read one referral at the same moment, and
without the guard the later write wins wholesale: a qualification landing after a cancellation restores
QUALIFIED and clears referee_subscription_cancelled_at, because its snapshot never saw the
cancellation. The referrer then keeps 5% off a subscription that has ended, the tier counts it, and nothing
anywhere says so.
What a refusal costs is a retry, not correctness. The cron counts it as a failure — so the CronJob exits non-zero — and its next run re-reads and decides again, which turns the clobber into a correct no-op. The webhook paths propagate, so Stripe or Clerk redelivers and the retry reads fresh. It is deliberately not a retryable error: repeating the same write cannot help, because its snapshot is stale by definition.
Note what is not here: no foreign keys to organization. They were dropped deliberately, because
ON DELETE CASCADE would destroy the referral history — including qualified_at and the payment
timestamps — the moment an organization was hard-deleted.
The link between a referee and their referrer #
The referral row holds the link: referral.referrer from the moment the invitation is sent, and
referral.referee once the referee makes an organization.
Between those two points the link lives on the referee’s Clerk user, as
publicMetadata.referrer_code — the referrer organization’s 11-character referral_code, not a UUID —
placed there by the invitation and carried onto the user at signup.
It is the only thing that tells organization.created this organization belongs to a referral. Lose it
and the referral is never linked, the referrer earns nothing, and nothing errors — which is why the invitation
is the single place it is written, and why it is worth checking first when an attribution goes missing.
The code only arrives if the referee redeems the invitation ticket #
Clerk copies an invitation’s publicMetadata onto the user only when the signup redeems the invitation
ticket. The “Accept invite” link points at the invitation’s redirect_url with __clerk_ticket appended, and
that page has to consume it. Two things have to hold, and both are easy to get wrong:
redirect_urlpoints at/sign-up, not the app root. The root has no signup handling, so it drops the ticket silently: the invitation still flips toaccepted— Clerk matches on the address — the user is created withpublicMetadata: {}, and nothing is attributable.- Middleware must not bounce an authenticated visitor off
/sign-upwhen a ticket is present. A redirect discards the query string along with the ticket.
This is the fragile joint in the whole design. A referee who signs up by any route other than the invitation link gets no `referrer_code`, and Clerk still marks the invitation accepted — so the outward signs of success are all present while the attribution is gone.
That absence is treated as a decision, not a lookup failure. No code means the referee did not arrive
through the invitation, so nothing is attributed: no accepted_at, no link. Correlating on the address alone
would be easy and is deliberately refused — it would credit a referrer whenever somebody they had invited
signed up independently, which is a referral program paying out on signups it did not cause.
When one address has been invited more than once #
UNIQUE(referrer, referee_email) is composite, so several referrers may each have an open invitation to the
same person. Exactly one of them can win: the referee creates one organization, and UNIQUE(referee) gives it
to whichever referral was linked.
So a signup closes every other open invitation to that address, whether or not it used one:
| The referee signs up… | The invitation they used | Every other open invitation |
|---|---|---|
with no referrer_code |
— | ended, SIGNED_UP_INDEPENDENTLY |
| on referrer B’s invitation | B’s records accepted_at and stays open |
ended, SIGNED_UP_INDEPENDENTLY |
The losing rows are closed rather than left to expire because they cannot become anything else — their referrers can never earn from this person, and a row reading “Invited” invites a follow-up that cannot help. Ending them turns up to a month of false hope into an answer.
SIGNED_UP_INDEPENDENTLY is reused rather than adding a SUPERSEDED reason, for two reasons. It is true from
the losing referrer’s side — the referee did arrive by a route that was not theirs — and it tells them nothing
about who else invited the same person, which is not theirs to know.
The case is logged rather than ignored, because it produces a support question nobody could otherwise answer: the referrer sees Invited, the referee insists they signed up, and both are right.
Four guards sit behind the lookup that follows, which keys on (referrer, referee_email) and cannot tell one
situation from another:
- Only an
AWAITING_PAYMENTreferral is linked, or given anaccepted_at. The code stays on the user permanently, so a referee whose invitation was withdrawn or declined would otherwise pick up a referee organization and a consent timestamp on the strength of signing up months later. Nothing financial follows from that — the tier counts onlyQUALIFIED, and the referee coupon gates onAWAITING_PAYMENT— but a row reading both “never taken up” and “consented at” is a contradiction every reader would have to resolve. - First organization wins. A referee who makes a second organization does not move their referral to it, because the tier counts the subscription the row already tracks.
- The organization must not predate the invitation. An account that already existed was not produced by
this referral, and may well be a paying customer already. With joining no longer linking, this is the last
guard against a referral attaching to an organization that was already there — and it still earns its place,
because the insert branch is not only reached by a fresh signup: an
organization.updatedfor an organization we have never stored reaches it too, and that organization can be any age. - One referral per referee organization, which the
UNIQUE(referee)index also enforces. A second referrer’s invitation to somebody at the same company cannot steal it.
Where the link is made, and why it is there #
Inside the organization upsert, on the branch that creates the row. Not in the organization.created
handler, which is the obvious place and the wrong one.
organization.created and organization.updated both reach the same upsert and race. Each reads, each
finds nothing, each inserts, and the loser takes a duplicate-key violation on organization.external_id — in
YugabyteDB that aborts its entire transaction, so every statement after it is ignored. Whichever handler loses
therefore does none of the work that followed the insert. Putting the link in either handler means it runs
only when that handler happens to win; putting it on the insert means it runs exactly once, whichever event
got there first.
The race predates the referral program and is not otherwise fatal — the losing handler returns an error, Clerk redelivers, and the second attempt finds the row and takes the update branch. What it is fatal to is work placed after the insert on the assumption that both handlers complete — which is exactly what the link is.
The referee joins an organization instead of creating one #
Nothing is linked, and that is the rule rather than a gap. A colleague sets the company up first, the
referee accepts and joins it, and organizationMembership.created records the membership and stops there.
The reason is what a referral is paid for. It earns on the subscription the referee’s own organization starts, so the referee has to create that organization and it has to pay. An organization that already exists is already somebody’s — frequently a paying customer’s — and crediting a referrer for joining it would hand them a recurring 5% off a subscription that was never theirs to win. That is precisely what the send-time already-a-Tofu-user check refuses, and being user-level only, it cannot always catch the case at send time.
Only the create branch links, so a referee who joins is a referral that stays on Signed up and then ages into nothing. The referrer is not told why, which is the one rough edge here: the row looks identical to a referee who signed up and has not paid yet.
**What this gives up.** `organizationMembership.created` was also the backstop for an organization whose payload carried no `created_by` — Clerk permits that, and the create branch then has no candidate user to look up a `referrer_code` on. With the membership path gone, such a referral never links and nothing retries it. That is narrow rather than theoretical: the referee creates their organization in the app, where Clerk always records a creator. `created_by: null` comes from organizations made through the Backend API, which this flow no longer does. It surfaces as `try_link_referral`'s `error!` and as a referral stuck on "Signed up".
Validation when an invitation is sent #
Applied in this order. The numbering matches the table on ReferralInvitationCreateService::validate,
so the two can be read against each other.
| # | Rule | Refusal |
|---|---|---|
| 1 | The address must parse. EmailAddress is the only parser, here and everywhere else an address is read: trimmed, exactly one @, non-empty local part and domain, no whitespace, and RFC 5321’s limits of 254 characters for the address and 64 for the local part. Any plus tag is kept — see two invitations to the same mailbox |
400 |
| 2 | This referrer must not have invited the address already — a cheap pre-check ahead of the guarantee, which is the UNIQUE(referrer, referee_email) index. See the same address is invited twice |
409 |
| 3 | The referrer must be inside their invitation allowance — see the daily invitation cap | 429 |
| 4 | The address must not already belong to a Tofu user — checked both in our app_user table and in Clerk, because the two disagree: a Clerk user exists from the moment they are invited, before any app_user row is written |
422 |
Nothing judges the referee’s address by its domain #
Not its provider. Legitimate sole practitioners in Malaysia and Singapore run their practice off Gmail, and refusing them would cost real referrals. Disposable addresses are Clerk’s job: it screens them when the referee signs up, so a list here would be our copy of Clerk’s, consulted one step earlier and protecting nothing extra.
And not whether it matches the referrer’s own domain. Customers run an organization per branch, so two organizations on one domain are the normal shape of a larger customer, not evidence of a self-referral — and each branch pays its own subscription. A rule refusing colleagues would refuse those referrals, and it would refuse every referral between two people who merely share a free provider.
What makes that safe is the pricing rather than any check here: a qualified referral returns 5% off the referrer’s own bill and costs a whole subscription, and the tier falls again as soon as the referee stops paying. See why the fraud controls are this light — the arithmetic there is the control, and it holds whoever the referee is.
`REFERRAL_BLOCKED_EMAIL_DOMAINS` is **no longer read**. The value still travels from Doppler into `AppConfig`, and nothing consults it. Removing that path is a [follow-up](#deferred-to-follow-up-tickets); until then, do not reason about it as if it were live.
**Rule 1 checks shape, not deliverability.** Nothing establishes that the address can receive mail, so a mistyped work address consumes an invitation and nobody is told — the referrer sees success and the referee never gets an email.
There is no developer exemption from any of these, and none is needed: the team can invite an address at their own domain, because no rule compares domains.
Rule 2 is told to the referrer plainly — it reveals nothing the referrer doesn’t already know, and a vague refusal would just generate a support ticket.
Rule 4 uses deliberately generic “not eligible” wording, identical to every other eligibility rejection. A specific message would turn the endpoint into an oracle for “is this company already a Tofu customer?”, which is a competitor’s question, not a referrer’s.
**Rule 4 is user-level only.** It catches an existing `app_user` and an existing Clerk user; it does **not** check organizations. The gap is somebody holding a *pending* Clerk invitation to an existing customer's organization — no user exists yet, so the referral send succeeds. If they later accept and join that organization, the [predates-the-invitation rule](#the-link-between-a-referee-and-their-referrer) is what stops the referrer being credited for an account that was already paying.
The daily invitation cap #
Ten invitations per referrer per rolling 24 hours, refused with 429 past that.
The cap exists because every send is irreversible spend on our side, not the referrer’s: a Clerk invitation, a referral row, and an email leaving through Clerk’s sending domain. An uncapped referrer spends our sender reputation and our Clerk quota. Ten is comfortably above any genuine day’s referring, so the referrer who hits it is either scripting or mistaken.
Two details make it mean something:
Every row counts, whatever became of it. A withdrawn or expired invitation still consumed an invitation and an email. Excluding them would make withdraw-and-reinvite an unlimited bypass.
A rolling window, not a calendar day. A calendar boundary hands out a fresh allowance at midnight in whichever timezone the server happens to run in, which is twenty invitations in the few minutes either side of it.
Where the check sits in the order matters. It runs after the cheap local checks and the duplicate pre-check — so a mistyped or repeated address still gets the answer that helps — but deliberately before rule 4. Rule 4 is the only check that costs a Clerk API call, and the only one that answers “is this address already a Tofu customer?”. Behind the cap, that answer costs an attacker ten questions a day; ahead of it, they could ask without limit, and every attempt we had already decided to refuse would still burn a Clerk round trip.
Edge cases #
Referee cancels before qualifying #
Status becomes SUBSCRIPTION_CANCELLED and the countdown stops without ever having counted toward a
tier. Nothing “dropped” from the referrer’s discount — it never arrived, and it can still arrive later
if the referee comes back. Referrers frequently expect
the discount at signup rather than at day 30, so this is a common misunderstanding rather than a
fault.
Referee cancels after qualifying #
The row moves to SUBSCRIPTION_CANCELLED, keeping qualified_at as history, and the referrer’s tier
falls by that referral’s 5% — their coupon is swapped down accordingly. A referrer at the 50% cap who
loses one referee drops to 45%, and their next referee’s own discount reverts from 10% to 5%.
The tier query needs no change to make this work: it counts QUALIFIED rows, and the row is no longer
one. The pair of timestamps is what distinguishes this from a referral that never qualified — see
what the history table shows, where it surfaces as ENDED
rather than DID_NOT_QUALIFY.
The referee’s payment fails inside the qualification window #
A failed payment is not an event this program reacts to. Dunning is Stripe’s business: it retries,
and if it never succeeds it deletes the subscription itself after about 15 days. Only that deletion
matters here, and it arrives as the same customer.subscription.deleted webhook as a voluntary
cancellation — so a referee who dunns out is handled by
the referee-cancels path, not by anything of its own.
The practical rule: once the first payment has succeeded, nothing changes the referral until a subscription-deleted webhook arrives. A card that fails on day 29 and recovers on day 31 is a non-event.
The qualification cron therefore does not re-check the subscription’s status, and that is deliberate
rather than an omission. Treating anything but active as cancelled means a
recoverable card failure landing on the single run that reaches day 30 permanently kills a referral that
should have qualified — the cron sees one instant, and past_due at that instant says nothing about
whether the payment recovers tomorrow.
The consequence to accept: a referral can qualify at day 30 while its referee is mid-dunning, and then un-qualify days later when Stripe gives up and deletes the subscription. That is self-correcting, and it is not a bug.
This rests on one Stripe setting. The end-of-dunning action must be “cancel subscription”. If it is
set to mark the subscription unpaid, or to leave it past_due indefinitely,
customer.subscription.deleted never arrives and the referral sits QUALIFIED against a dead
subscription for good — the same fraud hole as making qualification terminal, reached by configuration
instead of code.
A cancelled referee resubscribes #
The referral revives. A cancelled referral is not the end of the relationship: if the referee pays again, the 30-day countdown simply starts over from that payment. The referrer’s organization link is the durable thing — as long as the referee’s Clerk organization still exists, the referral it belongs to can earn again.
The row is reused rather than replaced — referee is unique per organization, so there is only ever one
referral per referee. A restart moves it back to AWAITING_QUALIFICATION with the new payment as
referee_payment_succeeded_at, and qualified_at is retained from the earlier run as history. The
history table therefore shows the row as Qualifying again, read off the status, and the retained
qualified_at is what records that it had earned before.
referee_subscription_cancelled_at is cleared, which keeps the column honest: it is set exactly when
the subscription the row currently tracks has been cancelled. A revived row carrying a stale cancellation
date while reading as Qualifying would force every reader to special-case the combination, and the
ENDED / DID_NOT_QUALIFY split still works without it — churn again and the date is set again, with
qualified_at beside it.
The row also starts tracking the new subscription id, which is what makes revival safe against
Stripe’s webhook ordering: a customer.subscription.deleted for the old subscription arriving after the
new payment matches no referral and is a no-op, rather than cancelling the referral that just revived.
What must not restart the countdown is a renewal. invoice.payment_succeeded fires on every monthly
invoice, so the restart is matched on status — only a cancelled row, or one that has never been paid for,
begins qualification. Were AWAITING_QUALIFICATION or QUALIFIED accepted as well, every renewal would
push qualification 30 days out and nothing would ever qualify.
Referee organization is deleted #
It depends on whether they ever paid, and the referral row answers that itself: a
referee_stripe_subscription_id is written the moment a payment succeeds.
| The referee | On organization.deleted |
Why |
|---|---|---|
| has paid at least once | nothing happens | Deleting a Clerk organization does not cancel a Stripe subscription. They are still being billed, which is exactly what the tier is meant to represent |
| never paid | SUBSCRIPTION_CANCELLED, dated at the deletion |
Nothing else can ever end it, so the row would read “Signed up” forever |
For a paying referee, the deletion is not the end of anything. When the payments do stop — the customer
cancels, or the card fails and Stripe gives up — customer.subscription.deleted arrives and the
ordinary cancellation path moves the row and swaps the coupon down.
That lookup is keyed on the subscription id with no join to organization, so a soft-deleted referee
organization does not stop it matching. It also closes the obvious farming route rather than opening one:
deleting the organization does not stop the charges, so nobody can delete their way out of paying while
keeping a discount.
For a referee who never paid, no such event is coming. The free trial is Clerk metadata — there is no Stripe subscription behind it — and a trial that lapses does not lock the organization, it only stops it doing anything. So nothing would ever fire, and every exit from the row is closed: qualification needs a payment that will not arrive, the cancellation lookup is keyed on a subscription id the row does not have, and withdrawal is refused once the invitation has been accepted. Ending it here is the only thing that can.
It lands on SUBSCRIPTION_CANCELLED with no qualified_at, so the referrer reads Didn’t qualify — true,
and terminal. No coupon work is needed, and that is not an omission: a row that never paid never counted
toward the tier, so the referrer’s discount is already correct.
**The referral is still spent.** `referee_email` stays on the row, so `UNIQUE(referrer, referee_email)` blocks inviting that address again, and `UNIQUE(referee)` pins the row to the deleted organization — a referee who comes back with a new organization is not re-linked, because linking is first-organization-wins. The referrer cannot try again, and the same dead end as a [spent allowance](#two-limits-two-harms) applies.
This is the one place the Clerk event moves referral state, and only where Stripe cannot: with no subscription there is no second writer to race, so one writer per fact still holds.
The residual case is an abandoned trial — never paid, never deleted. That row keeps reading “Signed up” indefinitely, because nothing distinguishes it from a referee who is about to pay. Aging it out would need a deadline, and a lapsed trial has none: the organization stays reachable, it just cannot do anything.
Referrer cancels their own subscription #
referrer_lapsed_at is stamped on every referral row they own, and their referral coupon is stripped from
Stripe. This is permanent: a returning referrer climbs the tier again from zero. Lapsed rows never
count again, so a referrer who resubscribes and disputes their discount is seeing intended behaviour.
Any hand-negotiated, non-referral discount on the customer is left untouched — only known referral coupon ids are removed. The reverse is not true on the apply path; see The referrer already has a hand-negotiated discount.
The referrer already has a hand-negotiated discount #
A Stripe customer carries a single discount, so referral coupons and negotiated coupons compete for
the same slot. Both paths that touch it refuse to disturb a discount this program did not grant, and
the check is the same on each: read the customer, and act only if the current coupon is one of ours —
referrer_coupon_5 … referrer_coupon_50, or the referee one-offs.
- Removing. A referrer’s own cancellation strips their referral coupon and leaves a negotiated discount completely intact.
- Applying. A qualification that would overwrite a foreign discount is refused, and logged at
error!naming the customer, the coupon it wanted to apply, and the one already there.
The referee coupons are in that set on purpose: a referee who has not paid yet still holds an unconsumed one-off, and if they become a referrer before that invoice lands, their own coupon must not read as somebody else’s and block what they earned.
**A refused apply leaves the referrer earning nothing in Stripe.** The tier is recorded — their history and `discount_percentage` are right, and the referral page shows what they earned — but the coupon on their Stripe customer is not touched, so their invoice does not change. Nothing retries it, and nothing tells the referrer. That is the deliberate trade: overwriting silently was worse, and which discount *should* win is a commercial decision the code cannot make. It needs somebody to compare the negotiated discount against the earned one and set the winner by hand. The `error!` is the only signal, so it is worth alerting on.
So it is still worth knowing which accounts hold a negotiated discount before enabling the program for them — not because their discount is at risk any more, but because their referrals will earn nothing visible until the conflict is resolved.
A referee who later becomes a referrer is not affected by this, which is worth knowing because it looks like it should be. Their referee discount applies to one invoice and is consumed there, whereas qualifying their own first referral takes at least the 30-day window on top of their referee’s billing cycle. The slot is long empty by the time a referrer coupon needs it. Only an open-ended discount — which is what a negotiated one is — is still sitting there to be overwritten.
The referee’s discount never arrived #
The only part of this program the referee themselves experiences, so it is worth being able to answer. Check in this order:
- The referral’s status. If it is past
AWAITING_PAYMENT, the attach gate is shut. Either the coupon went on before payment (check the Stripe customer for a past discount) or it never did and now cannot. - The Stripe customer’s discount history. A
referee_coupon_5/referee_coupon_10consumed on the first invoice is the success case and shows as a past discount, not a current one. - bonsapi logs for “Failed to apply referee coupon”. The attach is best-effort, so this is where a
swallowed failure surfaces. Look for it on
organization.createdfirst, then on anyorganization.updatedafterwards — if it failed on creation and on every later update before the first payment, the discount is unrecoverable through the normal path. - Whether the referee has a Stripe customer at all. The customer is created on
organization.created, also best-effort, so a failure there means there was nothing to attach a coupon to.organization.updatedcreates one if it is missing, so this self-heals unless the referee paid first. - Whether
referrer_codeis on the referee’s Clerk user. Without itorganization.creatednever recognises the organization as a referral, so it links nothing and attaches nothing — and the log above stays silent, because no attach was ever attempted.
There is no reissue path. Correcting it means a manual credit or a one-off Stripe coupon.
Typo’d email address #
A wrong address leaves an invitation nobody can accept and a referral row nobody will ever match. Nothing
else: no organization, no Stripe customer, no organization row to clean up.
The recovery is cheap: invite the corrected address. It is a different key under
UNIQUE(referrer, referee_email), so it inserts cleanly with the dead row still in place. The wrong row
cannot be tidied away — nothing deletes a referral row — so it stays in the history and expires, which is
untidy rather than harmful.
It does spend one of the referrer’s ten daily invitations, and nothing refunds it.
An undeliverable address #
Nothing detects one. There is no bounce handling, and no way to delete the referral row one leaves behind.
Bounce handling is not available in this shape: Clerk sends the invitation email, so Mailgun is not in that path and its bounce webhook never sees these sends, and whether Clerk exposes delivery or bounce events at all is unresolved. Columns nothing could ever write would be worse than none — they read as coverage — so there are none.
The answer is to refuse the address before an invitation is spent, in
rule 1 — the earlier and cheaper place to catch it, and one that
costs no send. That half is not shipped either; see TODO(ENG-7204) and
deferred to follow-up tickets.
**Until deliverability lands, a mistyped work address fails completely silently.** The send succeeds, the referrer is told it worked, and the only symptom is a referral that reads "Invited" until it expires. Nothing sweeps for it and nothing reports it. This is the largest known gap in the program, and the reason it is tolerable is that the referrer usually knows the address they typed and their own client. It is not tolerable indefinitely.
Invitation is never accepted #
accepted_at stays null and nothing else exists to go stale. Clerk invitations expire after 30 days by
default, so this is not a rare edge — it is the eventual state of every invitation that is not taken up,
and the referee may simply have deleted the email or never seen it.
The referrer resends it. That is a first-class action rather than a workaround, and it is the reason
the referral row carries send_count and last_sent_at even though Clerk owns invitation delivery:
Clerk owning the email does not mean Clerk owns the referrer’s relationship with the invitation.
last_sent_at is written on the first send, not only on resends — the initial invitation sets it
alongside send_count = 1. EXPIRED derives from it, so an invitation that was sent once and never
resent is precisely the case that most needs the value, and leaving it null there would mean the row
never expires.
A resend does four things, in this order:
- Create another invitation for the same address, carrying the same
referrer_code. - Point
clerk_invitation_idat the new one. - Bump
send_countand movelast_sent_at. - Revoke the invitation it replaced.
Clerk exposes no way to re-deliver an existing invitation — the Invitations API offers create, bulk create,
list and revoke, nothing else — so a resend is necessarily another create, and ignore_existing makes the
duplicate legal.
The order is the point. Revoking last means the referee is never left holding nothing: the replacement is already out and recorded before the old link stops working. Revoking first would introduce exactly that window, for no gain.
The invariant it buys: clerk_invitation_id names the only live invitation. Without step 4 each resend
would leave another working link — up to five per referral — and the row would name only the newest, so
withdrawal would have to go hunting for the ones it had forgotten.
Step 4 is best-effort. The send has already happened and been recorded, so a failed revoke must not be
reported as a failed resend; it leaves one extra usable link, logged at error!. Not an attribution risk,
because every invitation to that address carries the same referrer_code.
Step 2 keeps the row legible rather than making acceptance work: the signup event is correlated by the
referrer_code on the new user plus their address, not by invitation id.
Step 3 happens after Clerk accepts the send. A refused send costs no allowance and does not move the cooldown, so a referrer can retry immediately rather than losing a send they never got.
Two limits, two harms #
They are independent and neither bounds the other:
| Limit | Protects | Value |
|---|---|---|
| Daily invitation cap | Our sending domain, from one referrer’s volume | 10 new invitations per rolling 24h |
| Per-invitation allowance | One prospect’s inbox, from one referrer’s persistence | 5 sends per invitation, the first included |
| Resend cooldown | The same inbox, from the whole allowance being spent at once | 30 minutes between sends |
A resend does not consume the daily cap, because it creates no referral. Ten new invitations a day says nothing about how many times any one of them may be sent again, which is why the per-invitation limits exist at all.
The allowance bounds how many emails a prospect receives; the cooldown bounds how fast. Without the cooldown the whole allowance can be spent in a minute of clicking, which is the shape the abuse actually takes — and it also absorbs a double-click that beats the disabled button.
An expired invitation is still resendable. EXPIRED derives from last_sent_at plus the invitation
lifetime, so sending again makes the row live. Refusing there would block resend in exactly the case it
exists for.
**A spent allowance is a dead end.** Five sends made and the invitation unaccepted, and there is no way back: `UNIQUE(referrer, referee_email)` blocks re-inviting the address, and nothing deletes the row that blocks it. If that prospect later does want in, the referrer cannot invite them, and a signup on their own earns nothing — it ends the referral as `SIGNED_UP_INDEPENDENTLY`. Accepted for now, on the grounds that five ignored emails is a fair signal. The cheap fix if it bites is to let a referrer start a **fresh cycle on the same row** once the invitation has expired — reset `send_count`, new invitation, no new row and no unique-index conflict. That is delete-and-reinvite collapsed into one action, and cheaper than building a delete.
Doing the reissue outside this path — in the Clerk dashboard, say — is what produces the broken state described in step 3. It is not fatal, but it is not repairable through the UI either.
The referrer withdraws an invitation #
Withdrawing moves the referral to INVITATION_ENDED with reason WITHDRAWN_BY_REFERRER, and revokes its
Clerk invitation — the one clerk_invitation_id names, which is the only live one because a resend revokes
what it replaces. It is offered only while accepted_at IS NULL.
The row is ended before Clerk is told, and a Clerk failure does not undo it. The reverse order is worse: a
revoked invitation behind a row still reading “Invited” tells the referrer their invitation is live when
nothing can accept it. A surviving link after a failed revoke earns nobody anything either way — linking and
consent both require AWAITING_PAYMENT, which a withdrawn referral no longer is.
That guard is load-bearing rather than cosmetic. INVITATION_ENDED means “this invitation was never
taken up”, and every consumer reads it that way — so allowing it on an accepted referral would put a
live, paying organization into a status whose whole meaning is that no organization exists. What
payment_succeeded should then do is undefined, and the referrer’s own view would claim they withdrew
a customer who is actively earning them a discount. After acceptance the referral belongs to the
subscription lifecycle, and the only things that end it are the ones under
Cancellation and revocation.
Withdrawal is not reversible, and there is no way back. referee_email stays on the row, so
UNIQUE(referrer, referee_email) blocks inviting that address again, and nothing deletes the row. A
mistaken withdrawal costs that referral permanently; if the referee later signs up on their own it ends as
SIGNED_UP_INDEPENDENTLY and earns the referrer nothing.
The UI therefore confirms before withdrawing, naming the address rather than relying on row position — the table is paginated and rows move. That is the only guard against a fat-fingered click, which is why it is a dialog rather than a button.
The same address is invited twice #
A unique index on (referrer, referee_email) rejects the second attempt. That index is the only
double-submit guard — there is no token and no lease — so the send button is also disabled on
click. A double-click that beats the round trip is caught by the index, not by the UI.
Inviting the same address again on purpose is a resend, which reuses the row rather than trying to insert a second one.
A repeat send is refused even when the previous one never reached Clerk. A referral whose
clerk_invitation_id is null is one whose invitation was never created — the referral committed, then the
Clerk call failed. Tempting to let the endpoint quietly finish that send, but deciding when a referee may be
contacted again is what resend is for, and a narrower copy of those rules living in the send path would mean
two places deciding it. Resend handles that row: it has an allowance and no cooldown to wait out, since
last_sent_at was set by the send that failed at Clerk rather than by a delivered email.
referee_email is NOT NULL, so the index constrains every row. That is worth stating because the
obvious alternative does not work: a nullable column would let unlimited rows through, since Postgres
treats NULLs as distinct under a unique index, and the one guard the flow depends on would quietly
stop guarding.
A referrer code is added after the referee already subscribed #
Nothing reconciles this, in either direction.
organization.created links an existing referral row to the new organization; it never creates one. A
referrer_code that appears by any other means — editing user metadata in the Clerk dashboard, say — links
nothing, because there is no row to link. And an organization created before the code was attached is
never revisited.
That is deliberate. Retroactive attribution has rules the events cannot supply — was the referee already a customer, has their first payment already been taken, whose tier does it count toward — so it needs to be a deliberate action, not a side effect of a metadata edit.
The referee accepts but never creates an organization #
They sign up first and create their organization second, so those two steps can come apart. The user
exists, carries referrer_code, and has consented — but there is no organization, so referral.referee is
still NULL, no Stripe customer exists, and no referee coupon has been attached.
Nothing is wrong with that state and nothing needs repairing: the referral sits in AWAITING_PAYMENT and the
next organization.created for that user completes it. The invitation lifetime still applies, so an
abandoned signup ages out like an unaccepted invitation.
It does matter for diagnosis. “The referee accepted but the referrer sees nothing” is two different faults —
accepted-without-an-organization (expected, wait) and organization-created-without-linking (a bug, check
referrer_code on the user). Look at the user’s metadata before anything else.
Two invitations to the same mailbox #
Plus tags are kept. client+tofu@acme.com and client@acme.com are two different addresses to the
UNIQUE(referrer, referee_email) index, to the already-in-Tofu check, and to Clerk.
The reason is delivery. A Clerk invitation is address-bound, so the referee is written to at exactly the address stored — and a referee who tagged their address on purpose, to file the mail or to see who passed it on, asked for that tag. Stripping it would send their invitation somewhere they did not give us.
The cost is that one mailbox can hold several referrals, one per tag, and nothing here notices: the address that reaches Tofu is a different string each time. The daily cap is what bounds it — ten a day, tags included.
Case is the part that is normalized away, and only in comparisons: every check lowercases before it
compares (EmailAddress::raw_lower), while the address keeps the case the referrer typed. The stored
referee_email therefore has to be the lowercase form, because user.created lowercases the address it
receives before matching it — store it as typed and a referrer who capitalised anything loses the
attribution silently.
The referee abandons onboarding #
org_has_setup stays false and they would otherwise be stuck on that screen, so the page has a skip path.
It collects sales fields only — the referee typed their company name when they created the organization. The
gate is cosmetic: it is a frontend check on client-readable metadata and is bypassable.
That is acceptable for collecting a name and some sales fields — consent is already captured at signup,
so nothing legally load-bearing depends on this page — and nothing billing- or security-relevant may
come to depend on it either.
The derived tier and the Stripe coupon disagree #
The tier is derived on read, so the database can say three qualified referrals while Stripe still
holds referrer_coupon_10 — a coupon swap that failed. Of all the “my discount is wrong” reports,
this is the one that is a genuine defect. Compare the qualified count against the coupon actually
attached to the Stripe customer.
A qualified referral whose subscription is already gone #
Rows already sitting in QUALIFIED against a subscription that is gone do not fix themselves. The
daily cron only looks at AWAITING_QUALIFICATION rows, and the cancellation webhook that would have
moved them has already been delivered and discarded — so there is no event left to replay.
SQL alone cannot identify them: whether the referee’s subscription still exists is Stripe’s answer, not ours. The procedure is therefore a one-off script, not a migration:
-- The candidate set. Every row here needs its subscription checked in Stripe.
SELECT r.id, r.referrer, r.referee, r.referee_stripe_subscription_id, r.qualified_at
FROM referral r
WHERE r.status = 'QUALIFIED'
AND r.referrer_lapsed_at IS NULL
ORDER BY r.referrer;
For each row whose subscription is absent or not active, apply the ordinary cancellation path — move it
to SUBSCRIPTION_CANCELLED, keep qualified_at, then resync that referrer’s coupon once after all of
their rows are done, so a referrer losing three referees is not walked down one tier at a time.
The candidate set is necessarily empty at first release, because the table starts empty. This is a procedure for later, and the query above is how to check rather than assume.
A referrer reaches ten real referrals #
They sit at 50% for as long as all ten stay subscribed. This is not fraud and not a bug — it is the program working. It does mean they have effectively become a channel partner without a partner agreement, which is worth escalating commercially rather than technically.
Why the fraud controls are this light #
Self-referral is uneconomic by construction, and it is the pricing that makes it so rather than any control we built.
A self-referral costs the attacker one full subscription on the cheapest plan and returns 5% off their own bill. At current list prices — $864 cheapest, $8,388 dearest, a 9.7× spread:
| Attacker on | Earns per fake referee | Pays per fake referee | Ratio |
|---|---|---|---|
| Dearest plan | 5% of $8,388 = $419 | $864 | 2.1:1 against them |
| Cheapest plan | 5% of $864 = $43 | $864 | 20:1 against them |
Stacking does not help, because both sides scale linearly: ten fake referees to reach the 50% cap costs $8,640 and returns $4,194. Still 2.1:1 against.
Break-even would need 5% of the attacker’s own bill to cover a whole cheapest plan — a 20× spread between cheapest and dearest, against the 9.7× that exists today.
All of that assumes the fake referee has to keep paying. It does, because the tier falls when a
referee cancels — that rule is what makes this arithmetic hold, and it is therefore the only real fraud
control in the program. If a qualified referral were permanent instead, a single month on the cheapest
plan would buy 5% off the attacker’s bill for as long as they remained a customer, and the ratio inverts
from 2:1 against them to overwhelmingly in their favour. Anyone tempted to simplify cancellation
handling by making QUALIFIED terminal should read this section first.
That is the entire defence, and it is a pricing property, not a security property. A cheaper entry tier, a free tier, or annual prepay discounts on the top plan all narrow the gap; a free tier removes the cost side altogether and makes farming free. Revisit this section on any pricing change — the figures above are the whole argument, so if they move, so does the conclusion.
The invitation email #
The referee receives Clerk’s default invitation email — an invitation to Tofu itself, not to an organization. Clerk’s templates are per-instance rather than per-invitation, so referral-specific copy would also fire for every other instance invitation we send.
The practical consequence: the email carries no referrer context. The referee’s only cue is that they were expecting it, so referrers should give their client a heads-up before sending.
The coupon-earned email #
When a referral qualifies, the referrer is told. The daily cron publishes to RabbitMQ,
bonsai-notification consumes it, and Mailgun renders the template at a pinned version — so unlike the
invitation email, this one is ours to word.
One job, two templates. A referrer at the cap earned nothing from this referral, so telling them their
discount rose would be a lie. ReferralCouponEarned carries beyond_discount_cap and the consumer picks:
| Qualified referrals | Template | What it can say |
|---|---|---|
| 1–10 | referral |
the discount just moved, and to what |
| 11+ | referral_max |
another client joined, the discount is already at its 50% maximum |
The boundary is > 10, not >=: the tenth referral earned the final 5%, so it is an ordinary reward. Only
the eleventh onward has nothing to give. ReferrerTier clamps its tier field at ten, so the predicate
reads total_count — a check written against tier is always false, which is how this shipped broken once.
Three properties worth knowing:
- One email per run per referrer, not per referral. The cron recomputes a referrer’s tier once and sends their new discount percentage, so a referrer whose third and fourth referees qualify on the same night gets one email saying 20%, not two. Past the cap the same holds, and the count in the email is the real total rather than ten.
- It is best-effort. The coupon is applied to Stripe first; a failed send is logged and counted in the
run’s
failures, which makes the CronJob exit non-zero, but the discount is already in place. The referrer’s next invoice is correct whether or not the email arrived. - Nothing tells them when the tier goes down. A referee cancelling silently reduces the discount — see the referrer already has a hand-negotiated discount for the other half of that asymmetry. This is the likeliest source of a “my discount changed” ticket.
What the API exposes #
Four endpoints, all scoped to the caller’s own organization. A referral belonging to anyone else answers
404, never 403: the referrer should not be able to learn that a referral id exists.
| Endpoint | Answers with | Refusals |
|---|---|---|
GET /api/v1/referrals |
the discount, whole-history aggregates, and one page of history | 400 for n_per_page outside 1–100 or page_number above 10,000 — both bounded because they reach SQL as LIMIT/OFFSET |
POST /api/v1/referrals/invitations |
the new referral row | 400 unparseable, 409 already invited, 429 daily cap, 422 already a Tofu user — in that order |
POST /api/v1/referrals/invitations/{id}/resend |
the row, with send_count moved |
404 not yours, 409 accepted or ended, 429 allowance spent or cooling down |
POST /api/v1/referrals/invitations/{id}/withdraw |
204 |
404 not yours, 409 accepted or already ended |
The webapp asks for 20 rows a page. The aggregates are not paginated: the discount comes from a live
COUNT of qualified rows, and total_count / in_progress_count / all_lapsed are derived from a read of
every referral the organization owns. That read is unbounded, which is fine at the scale the cap allows —
ten qualified referrals is the maximum that earns anything — but it is the one query here that grows with a
referrer’s history rather than with the page.
Every judgement the UI needs is decided server-side and sent with the row: display_status,
display_date, can_resend, sends_left, can_withdraw. The webapp renders those rather than
re-deriving them, which is what keeps one definition of “expired” or “no resends left” in the codebase
rather than one per consumer.
What the referral history table shows #
The referrer’s history table does not render status directly. The raw enum has five values and
the table needs nine, so the API returns a derived display_status alongside the raw fields.
The derivation is done server-side, in one place, because rules like “qualified then ended” versus “never qualified” silently drift when each consumer reimplements them — and the history table is not the only future reader.
Evaluated in order; first match wins.
display_status |
Label | Derived from |
|---|---|---|
WITHDRAWN |
Withdrawn | status = INVITATION_ENDED and reason WITHDRAWN_BY_REFERRER, or no reason recorded |
DECLINED |
Declined | status = INVITATION_ENDED and reason DECLINED_BY_REFEREE |
SIGNED_UP_ELSEWHERE |
Signed up elsewhere | status = INVITATION_ENDED and reason SIGNED_UP_INDEPENDENTLY |
EXPIRED |
Expired | accepted_at IS NULL AND status = AWAITING_PAYMENT AND last_sent_at + <Clerk lifetime> < now |
INVITED |
Invited | accepted_at IS NULL AND status = AWAITING_PAYMENT |
SIGNED_UP |
Signed up | status = AWAITING_PAYMENT |
QUALIFYING |
Qualifying | status = AWAITING_QUALIFICATION |
EARNED |
Earned | status = QUALIFIED |
ENDED |
Ended | status = SUBSCRIPTION_CANCELLED AND qualified_at IS NOT NULL |
DID_NOT_QUALIFY |
Didn’t qualify | status = SUBSCRIPTION_CANCELLED AND qualified_at IS NULL |
**`DECLINED` is inert.** The derivation covers it and the label is translated, but nothing writes `DECLINED_BY_REFEREE` — the referee has no way to decline, which is [deferred to its own ticket](#deferred-to-follow-up-tickets). `WITHDRAWN` and `SIGNED_UP_ELSEWHERE` are both live: withdrawal is a referrer action, and an invited address signing up without the invitation ends the referral by itself. Whatever eventually writes the third must set the status, the reason and the timestamp **together** — the display status reads the status, the reason picks which of the three labels, and the date column reads the timestamp, so a partial write renders a row with no date or no explanation. `Referral::end_invitation` exists to make that impossible to get wrong.
Why the order and the extra states matter:
The invitation outcomes come first, and they do not collide with the subscription ones. The three ended
values describe an invitation that was never taken up; ENDED and DID_NOT_QUALIFY describe a subscription
that stopped. Because the two groups live in different status values, neither can be mistaken for the other
— an invitation nobody accepted is not reported as a customer who failed to reach 30 days, and a withdrawn
invitation is not reported as a churn.
The three ended values split on the reason, not on separate timestamps. They share
invitation_ended_at, because a row can only end once. A missing reason falls back to WITHDRAWN, which is
the only one of the three that asserts nothing about the referee — claiming they declined, or signed up
elsewhere, on the strength of a NULL column would state something about a person we do not know.
EXPIRED is derived, not stored. last_sent_at plus Clerk’s configured invitation lifetime is
enough, and deriving it means a resend automatically un-expires the row by moving last_sent_at. It
sits above INVITED so that an invitation past its lifetime stops claiming to be live — without it,
every unaccepted invitation eventually reads “Invited” forever, which is the state a referrer is most
likely to act on wrongly. The derivation mirrors a lifetime configured in Clerk, so if that setting is
changed in the Clerk dashboard, this has to change with it.
INVITED is the state the raw enum cannot express. A referral sits in AWAITING_PAYMENT from the
moment it is created, so without this rule an un-accepted invitation reads “Signed up” — when nobody
has signed up. accepted_at is what separates the two, and it is the one field here that status
cannot stand in for.
The status = AWAITING_PAYMENT clause is the other half, and it is the part that is easy to drop as
redundant. accepted_at is stamped by a webhook, so it can be missed —
and a referee whose acceptance event was lost still signs up, pays, and qualifies normally. Without
the status clause, that row would read Invited forever while actively earning the referrer 5%.
With it, the row falls through to whatever status says and only the (cosmetic) acceptance timestamp
is wrong.
SIGNED_UP covers both halves of signup. The referee creates their organization after their account, so
the row reads SIGNED_UP from the moment the account exists — whether or not they have got
as far as an organization. That is intentional: the referrer’s question is “did my client start?”, and the
answer is yes in both cases. The distinction matters to support, not to the referrer, and it is visible there
as referee IS NULL.
ENDED and DID_NOT_QUALIFY split what the enum conflates. Both are
SUBSCRIPTION_CANCELLED; qualified_at is what distinguishes a referee who earned for months and
then left from one who never reached 30 days. Collapsing them is most misleading in exactly the case
a referrer is most likely to ask about.
Organization deletion is deliberately not distinguished. A deleted referee organization and a plain
cancellation surface identically — ENDED if the referral had qualified, DID_NOT_QUALIFY if it never
did. The referrer is entitled to a coarse “are they still subscribed” signal and no more, and “their
organization was deleted” is more than that.
The referee’s name may be missing #
Until the referee creates an organization there is no organization name to show, so the table falls back to
the invited email address. A referrer always recognises the address they typed, so nothing is lost — but any
consumer reading referee_name must treat it as optional rather than assuming a joined organization.
The lapsed-referrer badge is separate #
referrer_lapsed_at is rendered as an additional badge — “No longer counted” — not as a
display_status value. It has to be additive because a lapsed referrer’s rows keep their status: a
qualified one still reads QUALIFIED, so the status field alone cannot tell you it stopped counting.
Its cause is also different in kind — the referrer’s own cancellation, not anything the referee did.
The date column #
One date per row, chosen by branching on the display status rather than on whichever timestamp happens to be set:
| Display | Date shown |
|---|---|
EARNED |
qualified_at |
ENDED, DID_NOT_QUALIFY |
referee_subscription_cancelled_at |
QUALIFYING |
projected: referee_payment_succeeded_at plus the qualification window |
EXPIRED |
the derived expiry — last_sent_at plus Clerk’s invitation lifetime |
INVITED |
last_sent_at, so the referrer can see how long it has been waiting |
SIGNED_UP |
accepted_at, the event that put them in that state |
WITHDRAWN, DECLINED, SIGNED_UP_ELSEWHERE |
invitation_ended_at — one date, because a row can only end once |
EXPIRED showing its expiry date is the point of that row: the expiry is the actionable fact, and a
referrer looking at it needs to know whether it lapsed yesterday or in March.
Picking by timestamp presence instead would break as soon as a row carries both qualified_at and
subscription_cancelled_at, which is precisely what an ENDED row is — it would keep showing the
stale “qualified on” date.
The projected date must be computed from the configured qualification window, not a hardcoded 30
days. A frontend constant that disagrees with REFERRAL_QUALIFY_AFTER_IN_MILLISECS produces dates
that are wrong with no visible symptom.
Both windows are therefore read from configuration and passed into the derivation rather than looked up
inside it: REFERRAL_QUALIFY_AFTER_IN_MILLISECS, which bonsapi and the referral cron share so the
projected date cannot disagree with the run that actually qualifies, and
REFERRAL_INVITATION_LIFETIME_IN_MILLISECS, which mirrors the Clerk instance setting. Nothing reconciles
that second one with Clerk, so changing the dashboard without changing the variable produces expiry dates
that are simply wrong.
Discounts are never summed client-side #
discount_percentage from the server is authoritative, derived from the live qualified count and
already capped. A referrer whose own arithmetic disagrees with their invoice files a support ticket;
summing rows in the UI is how that happens.
What a referrer can and cannot see #
A referrer sees a coarse indicator of whether each referee remains subscribed. This is inherent to the program, since their discount depends on it, and it is disclosed in the referral T&C the referee accepts when they sign up.
They never see plan names, amounts, payment failures, or past-due state. That is the referee’s financial position, not the referrer’s business.
Consent #
Consent is captured by the referee, on Clerk’s signup page — the same screen where they choose a login method (password or SSO). Clerk’s legal-consent checkbox is mandatory there: they cannot complete signup without ticking it. So acceptance and account creation are the same act, and there is no path to an accepted invitation without a recorded consent.
accepted_at on the referral row is stamped when the user.created webhook lands. That event is the
only moment we learn the consent happened.
No T&C version is recorded, on legal’s advice. Clerk holds legal_accepted_at on the user, which is the
authoritative record that consent was given; our row records only that this referral’s referee accepted, and
when.
**`user.created` has to stay subscribed in the Clerk dashboard.** The handler and the `ClerkEvent` variant are in place, but nothing records a referee's consent if the event is not delivered.
Because that webhook is the sole writer, accepted_at is only as reliable as the event stream — and it is
the one field on the row that a missed event silently leaves null. Clerk’s own record makes a gap recoverable,
but it has to be noticed first, and nothing on the referral row makes the absence obvious.
Two consequences of the checkbox living in Clerk rather than in our own page:
- It is instance-wide, not per-invitation — the same constraint as the invitation template. Every Tofu signup sees the same consent text, so it cannot be worded as referral-specific, and the referral terms have to be reachable from whatever that text links to.
- We do not control its copy at the point of consent. Anything that must be disclosed before the referee commits — notably that their referrer can see whether they stay subscribed — has to be in the linked terms, since there is nowhere on that screen to add it.
Consent lands before anything is provisioned for the referee. Only the referral row exists when they consent; their account is created by the act of consenting, and their organization comes after.
Marketing consent is a separate, unchecked opt-in. Pre-ticked opt-in does not hold up well under SG/MY PDPA. Consent is recorded with a timestamp.
Diagnosing a support question #
| Question | Where to look |
|---|---|
| “Why did my discount drop?” | A referee in SUBSCRIPTION_CANCELLED with qualified_at set — they earned and then left; then the same with organization.deleted_at set, which is a deleted referee org rather than a cancellation; then referrer_lapsed_at on their rows (their own subscription lapsed — permanent); then compare the qualified count against the coupon on their Stripe customer, the only one of these that is a defect |
| “Will my discount stay if my client leaves?” | No. The tier falls when a referee cancels, including after qualifying — and rises again if they resubscribe |
| “My client never got the invitation” | The Clerk invitation’s status first — Clerk owns delivery, so its dashboard is authoritative — then spam filtering. Nothing on our side detects an undeliverable address, so “Invited” says only that we asked Clerk to send it. Offer a resend |
| “Can you resend it?” | Yes — the referrer does it themselves from the history table, up to five sends per invitation and no more than one every 30 minutes. It reuses the same row and re-points clerk_invitation_id; see Invitation is never accepted. The row shows “No resends left” once the allowance is spent, which is a dead end |
| “I referred someone but nothing shows” | accepted_at IS NULL — invitation not yet accepted |
| “They signed up but nothing is linked” | referral.referee IS NULL with accepted_at set is the normal signed-up-no-organization state — wait for them to create one. If they already have an organization, check referrer_code on their Clerk user: without it, organization.created never linked them, and nothing logs a failure |
| “They signed up but I’m not earning” | referee_payment_succeeded_at plus the configured window; nothing happens until the daily cron passes that mark |
| “My client’s company name is wrong” | The referee names their own organization and can rename it in settings — nothing is derived on our side |
| “My client didn’t get their discount” | The referee’s discount never arrived — status past AWAITING_PAYMENT, the Stripe customer’s past discounts, then logs. Unrecoverable once the first payment lands |
| “My client deleted their account and now it says Didn’t qualify” | Expected, if they never paid: nothing else could ever end that referral, so deletion ends it. If they had paid, deletion changes nothing and the row moves only when Stripe reports the subscription gone |
| “My negotiated discount changed” | The referrer already has a hand-negotiated discount — the referral coupon replaces it on first qualification |
Metric to watch #
Referred-signup → first-payment conversion.
Tofu is a high-consideration purchase and this flow is entirely self-serve, so that number is the one worth establishing a baseline for early. The onboarding page is a drop-off point of its own and needs completion and abandonment tracked separately.
Organization creation is a second drop-off point. The referee signs up and creates their organization as
two steps, so signed-up-without-an-organization is measurable and worth measuring:
accepted_at IS NOT NULL AND referee IS NULL, aged.
Referred signups do not enter the CRM. Nothing in this flow creates a contact or company record, so a referred customer is invisible there until something else picks them up.
Deferred to follow-up tickets #
Everything in this section is a deliberate omission rather than a miss. It is listed here so a reviewer can tell the difference, and so none of it quietly becomes permanent.
| Item | What exists today | Tracked as |
|---|---|---|
| Referee declines an invitation | DECLINED_BY_REFEREE, the DECLINED display status and its translated label are all in place and derived — nothing writes the reason, so a referee who does not want the invitation ignores it and the row expires. It needs a tokenised unauthenticated endpoint, because the referee has no Tofu account at the moment they would decline |
Its own ticket, not yet filed |
| Deliverability check on the referee’s address | Rule 1 checks the address parses; nothing establishes that it can receive mail. This is the largest known gap in the program | ENG-7204, after the first release |
| Public terms page | REFERRAL_TERMS_URL points at gotofu.com/referral-terms, which is not published — the source of truth is an internal Google Doc. Consent is recorded either way, so nothing about the record depends on this |
Blocked on the marketing page |
Delete REFERRAL_BLOCKED_EMAIL_DOMAINS |
Nothing reads it, and nothing screens providers any more. The value still runs from Doppler into AppConfig |
Chore: drop the AppConfig field, then the Doppler key. Nothing references it in the deployments, so there is no manifest to unpick first |
Before the first release #
One switch is still in the branch and comes out: CI: "true" in docker-compose.yml silences a pnpm
prompt in the dev container and is unrelated to the referral program, but it entered on this branch, so it
is this branch’s to retest and drop.
The feature flag is now the rollout control, as intended. referral_program was force-enabled for
every organization in every environment while the flow was being tested end to end; that entry is out of
ALWAYS_ENABLED_FEATURES, so outside a local stack the Referrals section appears only for organizations
whose Clerk metadata enables it. LOCALLY_ENABLED_FEATURES still keeps it on in dev_local, so nobody has
to flip metadata to work on it — and nothing turns it on in dev_aws, preview or production until somebody
decides to.
Configuration to settle in Doppler, all read as environment variables:
REFERRAL_BLOCKED_EMAIL_DOMAINSneeds no value, and should be removed. Nothing reads it. It is not wired into the deployments — deliberately, because asecretKeyRefnaming a key that is not in Doppler leaves the pod unable to start, which is a poor trade for a variable with no reader.REFERRAL_QUALIFY_AFTER_IN_MILLISECSandREFERRAL_INVITATION_LIFETIME_IN_MILLISECSneed production values. The first has to match what the cron runs on, or the date a referrer is shown disagrees with when they actually qualify; the second has to match the invitation lifetime configured in the Clerk dashboard, because nothing reconciles the two and theEXPIREDderivation trusts our copy.REFERRAL_TERMS_VERSIONcan be deleted. Nothing reads it since ToS versioning was dropped.
Both Mailgun templates are in place, referral and referral_max, each pinned to a version in
bonsai-email. The pin is the thing to keep an eye on: a version string that does not name a version the
template actually has fails at send time, which surfaces as a logged failure and a non-zero cron exit with
the coupon already correctly applied.
Another prerequisite is already in place and worth knowing about rather than doing: user.created is
subscribed in the production Clerk instance. Acceptance and the consent record
hang off that event alone, and losing the subscription fails silently in the worst way — invitations get
accepted, referees sign up, and every referral sits at “Invited” forever with nothing logged. That is exactly
what happened in dev, where the only symptom was a status that would not move.
Related documentation #
- Billing System — the Stripe and Clerk integration the coupons ride on
- Email — Mailgun templates and delivery