docs

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

TermWhat it is
ReferrerThe existing customer who shared their code
RefereeThe new customer who used it
ProgramOne offer, written down: steps, amounts, eligibility, caps
ReferralOne referee's attribution to one referrer, pinned to the program that was live at the time
RewardOne 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.

curl -X POST https://api.example.com/v1/subscriptions \
-H "Authorization: Bearer $PLATFORM_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
  "subscriber_id": "sbr_01HXY7K2Q9",
  "plan_id": "pln_01HXY7K2Q9",
  "referral_code": "K7Q2M9A"
}'

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.

codeWhat happenedSuggested copy
referral_code_invalidNo such code"We don't recognise that code."
referral_code_inactiveThe 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_inactiveNo offer is running right now, or the one behind this code is paused"There's no referral offer running at the moment."
referral_not_eligibleAn always-on rule refused it (below)Depends which — error.message says
referrer_limit_reachedThe code's owner hit the offer's cap for the year"Your friend has reached their referral limit for this year."
attribution_window_closedPOST /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

curl "https://api.example.com/v1/referral_codes/K7Q2M9A?referee_plan_id=pln_01HXY7K2Q9" \
-H "Authorization: Bearer $PLATFORM_API_KEY"

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_cents has no pending key. 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" is rewards_pending, a count.
  • referees_has_more means the newest hundred are here and the rest are on GET /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.

TypeFires when
referral.createdA signup was attributed to somebody's code. No money has moved — this is the one to congratulate the referrer on
referral.reward.earnedA step resolved to an amount. Carries referral_id, step_index, recipient, amount_cents and the invoice that triggered it
referral.reward.fulfilledThe credit landed. Carries credit_ref
referral.reward.forfeitedA 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.completedEvery step on a referral has a final outcome
referral.voidedYour 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:

  1. 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.
  2. Read a referrer's code with GET /v1/subscribers/{id}/referral_code.
  3. Create a brand-new subscriber with referral_code set. Check GET /v1/referrals?referee_subscriber_id=… shows one open referral with its reward steps pending, and that referral.created arrived.
  4. 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.