Referrals
A customer hands out a code, a friend signs up with it, and when the friend pays their bill the customer gets money off theirs. heliumOS owns the whole of that: the codes, who referred whom, the rules of the offer, whether the friend actually paid, and the credit that lands at the end. Your backend passes one extra field at checkout and reads a summary for the customer's screen.
The offer itself is data your operations team writes in the dashboard — how many steps, what each pays, who qualifies, what the caps are — so changing it is an edit, not a deploy, and nothing already promised to a customer moves when it changes.
Referrals are a separately licensed product. Until it is switched on
for your operator, every route on this page answers
404 feature_not_enabled.
The vocabulary
| Term | What it is |
|---|---|
| Referrer | The existing customer who shared their code |
| Referee | The new customer who used it |
| Program | One offer, written down: steps, amounts, eligibility, caps |
| Referral | One referee's attribution to one referrer, pinned to the program that was live at the time |
| Reward | One step's outcome for one recipient — pending → earned → fulfilled, or forfeited, or failed |
A referral carries one reward row per step from the moment it is created, so the whole future of the offer is visible before anything happens and your screen can say "1 of 2 rewards earned" without knowing the rules.
Attribution at checkout
POST /v1/subscriptions takes an optional referral_code. Attribution
runs in the same transaction as the create: a subscription that fails
to create leaves no referral behind, and a code the offer refuses fails
the create.
A bad code fails the signup — it is never ignored
This is the one design decision worth reading twice. A code we cannot use
returns 422, and the subscription is not created.
Silently dropping it looks friendlier and is worse: the customer believes they used their friend's code, the friend is never paid, and neither of them finds out for a month. At checkout the customer can still fix it, so that is where we tell them.
If you would rather not surface any of this, validate the code first with
GET /v1/referral_codes/{code} and strip it from the create when it comes
back unusable.
Reason codes, and what to show a customer
Every refusal carries an error.code from a closed vocabulary, so you map
each to your own copy once and never parse a message.
code | What happened | Suggested copy |
|---|---|---|
referral_code_invalid | No such code | "We don't recognise that code." |
referral_code_inactive | The code existed and was retired — usually replaced when your operator moved platforms | "That code has been replaced — ask your friend for their current one." |
referral_program_inactive | No offer is running right now, or the one behind this code is paused | "There's no referral offer running at the moment." |
referral_not_eligible | An always-on rule refused it (below) | Depends which — error.message says |
referrer_limit_reached | The code's owner hit the offer's cap for the year | "Your friend has reached their referral limit for this year." |
attribution_window_closed | POST /v1/referrals on an order older than the offer allows | "It's too late to add a code to this order." |
The rules behind referral_not_eligible
Four of them are always on and no offer can turn them off. They exist because each one was, somewhere, paid for twice.
- One referral per referee, for life. A customer who cancels and comes back is not referred again.
- The referee must be a brand-new payer. Adding a line to an existing account is not an acquisition; neither is reactivating.
- You cannot refer yourself, or into your own group. Same person, same e-mail, or the friend joining the referrer's own plan.
- The referrer must still be a customer — checked again every time a step is earned, not just when the code was typed.
On top of those, the offer may carry its own conditions (for example "both parties must be on a paid plan"). Each one has its own reason code, returned in the same field.
Checking a code before checkout
An unusable code is still a 200, with valid: false and a reason — you
asked a question and this is the answer.
It tells you nothing about the owner. No name, no e-mail, no id. It is read off a code your prospective customer was handed in a screenshot, and the answer is rendered to a stranger.
Pass referee_plan_id to price the preview for the plan they are about to
pick; without it the amounts resolve against the offer's default. Either
way the figure comes from the same code that pays the real reward, so a
preview and a payout cannot disagree.
The "my referrals" screen
GET /v1/subscribers/{id}/referral_summary returns everything that screen
shows in one object, with the arithmetic already done.
{ "object": "referral_summary", "subscriber_id": "sbr_01HXY7K2Q9",
"code": "K7Q2M9A", "link": "https://example.com/ref/K7Q2M9A",
"program": { "id": "rfp_01HXY7K2Q9", "name": "Refer a friend",
"steps": [ { "index": 1, "recipient": "referrer",
"trigger": { "type": "referee_invoice_paid", "ordinal": 1 },
"amount_preview_cents": 1500 } ] },
"referred_by": null,
"counts": { "open": 2, "completed": 4, "void": 0 },
"rewards_cents": { "earned": 3000, "fulfilled": 7500, "forfeited": 0, "failed": 0 },
"rewards_pending": 4,
"caps": { "max_referrals_per_365d": null,
"max_reward_cents_per_365d": null,
"referrals_in_window": 6 },
"referees": [
{ "referral_id": "rfl_01HXZ8M3R0", "status": "open",
"attributed_at": "2026-09-01T00:00:00Z", "display_name": "J. S.",
"steps": [ { "index": 1, "status": "fulfilled", "amount_cents": 1500 },
{ "index": 2, "status": "pending", "amount_cents": null } ] }
],
"referees_has_more": false }
Four things to know before you render it:
- The code is minted on the first read. A customer who never opens the screen never gets a row, so there is nothing to backfill and nothing to create in advance.
- Referees are initials, and carry no id. This payload is shown to one
customer about other customers. A referee with no name on file, or one
whose record has been redacted on request, is
display_name: null— render the row, not an error. rewards_centshas nopendingkey. A step that has not been earned has no amount: what it pays depends on the plans both people are on when the friend actually pays. "How many are still coming" isrewards_pending, a count.referees_has_moremeans the newest hundred are here and the rest are onGET /v1/referrals?referrer_subscriber_id=….
Listing referrals from your back office
GET /v1/referrals is the read for your own systems and staff — filter by
referrer_subscriber_id, referee_subscriber_id, status or
program_id, paginate like every other list, and get both people and
every reward step embedded.
Unlike the summary, this one does name people, so it is subject to the
caller's PII permissions: a role without subscribers:read_pii gets the
contact fields nulled with pii_redacted: true on each embedded person.
Operator API keys see them in full.
{ "id": "rfl_01HXZ8M3R0", "object": "referral", "status": "open",
"origin": "checkout", "program_id": "rfp_01HXY7K2Q9", "code": "K7Q2M9A",
"referrer_subscriber_id": "sbr_01HXY7K2Q9",
"referee_subscriber_id": "sbr_01HXZ8M3R0",
"referee_subscription_id": "sub_01HXY7K2Q9",
"referee_plan_id": "pln_01HXY7K2Q9", "referee_plan_name": "Air",
"attributed_at": "2026-09-01T00:00:00Z",
"referrer": { "object": "subscriber" },
"referee": { "object": "subscriber" },
"rewards": [
{ "id": "rfw_01HXY7K2Q9", "object": "referral_reward",
"step_index": 1, "recipient": "referrer", "status": "fulfilled",
"amount_cents": 1500, "currency": "usd",
"credit_ref": "cred_01HXY7K2Q9",
"resolution": { "referrer_segment": "air", "referee_segment": "air" },
"fulfilled_at": "2026-09-15T00:00:01Z" } ] }
resolution is the resolver's working — which group each party's plan fell
into, which cell of the amount table was read. It is the answer to "why
$15 and not $30" when a customer asks.
Events
Six, and only the first fires at signup. Subscribe to them like any other webhook.
| Type | Fires when |
|---|---|
referral.created | A signup was attributed to somebody's code. No money has moved — this is the one to congratulate the referrer on |
referral.reward.earned | A step resolved to an amount. Carries referral_id, step_index, recipient, amount_cents and the invoice that triggered it |
referral.reward.fulfilled | The credit landed. Carries credit_ref |
referral.reward.forfeited | A step will never be earned, with reason: the friend cancelled, the invoice that would have counted was refunded, the referrer stopped being a customer, a cap was reached, or the referral was voided |
referral.completed | Every step on a referral has a final outcome |
referral.voided | Your support team reversed an attribution, with the reason they gave |
balance.credited also fires from the credit primitive with
kind: "referral" when a reward is paid. It and referral.reward.fulfilled
describe the same money from different ends — pick one to drive your
accounting, or you will count it twice.
Payloads carry public ids and no personal data about either party. Changes to the offer are operator configuration and are audited in the dashboard rather than broadcast as events.
How the reward actually reaches the referrer
A referral reward is an account credit, not a discount on a plan. It is granted in cents against the recipient's balance and spent automatically against their next bill, oldest credit first. Several rewards simply add up, which is what makes "six referrals, twelve months free" work without any special handling.
Two consequences your terms and your support team need to know:
- Tax on the plan is still owed. A credit applies after tax, so a customer whose $15 plan is fully covered by a $15 credit still receives a bill for the tax on it. Say so in your programme's terms. (A true 100% discount would avoid this, and cannot be stacked or bounded by a number of cycles, which is why the credit is what ships.)
- The payer gets the money, not always the referrer. On a family plan a member has no bill of their own, so a credit earned by a member is granted to whoever pays for them. The member's own plan still decides which amount is earned.
A reward that cannot be granted — a processor outage, the product switched
off mid-flight — is retried with backoff and then parked as failed, where
your operations team can see it in the dashboard and re-run it. Re-running
uses the same idempotency key the first attempt did, so a reward whose
credit did land is never paid twice.
Attributing after checkout
If your signup collects the code on a welcome screen rather than in the
order form, POST /v1/referrals attributes an existing subscription:
curl -X POST https://api.example.com/v1/referrals \
-H "Authorization: Bearer $PLATFORM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"referee_subscription_id": "sub_01HXY7K2Q9", "code": "K7Q2M9A"}'
It runs every rule the checkout path runs, plus one more: the subscription
has to be inside the offer's attribution window, or you get
attribution_window_closed. An offer with a window of zero days closes
this route entirely — which is the default, because attributing at
checkout is the shape the rules were written for.
Your support team has an unwindowed equivalent in the dashboard, for the customer who calls in a fortnight later having typed their friend's code wrong.
Testing it in sandbox
The whole flow works against the simulated processor, so you can watch a reward land without a real card. Switch your dashboard to the sandbox environment and:
- Write an offer in Referrals → Programs and activate it as the default. Use the simulate panel to check what a given pair of plans would pay before you switch it on.
- Read a referrer's code with
GET /v1/subscribers/{id}/referral_code. - Create a brand-new subscriber with
referral_codeset. CheckGET /v1/referrals?referee_subscriber_id=…shows oneopenreferral with its reward stepspending, and thatreferral.createdarrived. - Bring that subscriber's first real invoice to paid. The first step earns and then fulfils, and the referrer's balance moves.
See Testing in sandbox for the levers, and Webhooks for verifying the deliveries.