Coming in Broadcast 3.0. Until then, this page applies only to the Upcoming (edge) build.

Build a Referral Program

In this walkthrough you read a plugin that gives each subscriber a personal link. When a friend signs up through that link and confirms, the subscriber gets a referral. At a set count, the plugin adds a tag, and the tag can start a sequence that sends the reward.

The code is the official Referrals plugin, broadcast.referrals, with the ID local.referrals. A plugin of your own must use local..

Read Your First Plugin, the Events Hook and the Merge Tags Hook first.

How a code gets to your plugin

A public form address can carry a code:

https://example.com/join?ref=r1vc5e

This works for the hosted form page, and for a form embedded on a website. Broadcast stores the code on the new subscriber as signup_ref, and it adds that field to subscriber events.

There are three rules:

  • A code can use letters, digits, _ and -, up to 100 characters. Broadcast ignores any other code, and the signup still works.
  • The code is stored only when a new subscriber is created, not for an address that already exists.
  • Subscriber events always have confirmed_at. They have signup_ref only when one is set.

The double opt-in trap

On a double opt-in form, subscriber.created fires before the person confirms. If you count a referral at that moment, anyone can earn rewards with fake addresses.

So this plugin counts a person only when confirmed_at is set. It listens to two events:

Form Event with confirmed_at set
Single opt-in subscriber.created
Double opt-in subscriber.updated, when the person confirms

Now the plugin can see one person more than once: subscriber.updated also fires for later changes, and events can repeat. So it must count each person once.

The manifest

{
  "id": "local.referrals",
  "name": "Referrals",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "permissions": { "subscribers": "read", "tags": "write" },
  "hooks": {
    "events": ["subscriber.created", "subscriber.updated"],
    "mergeTags": ["referral_link", "referral_code", "referral_count"],
    "onSubscriberDeleted": true
  },
  "settings": {
    "form_url": {
      "type": "url",
      "label": "Signup page address (the page with your signup form)",
      "required": true
    },
    "rewards": {
      "type": "text",
      "label": "Rewards, one on each line: a number, then a tag (3 referral-sticker)"
    }
  },
  "events": {
    "referral.made": { "properties": { "referred_id": "integer" } }
  },
  "segmentRules": {
    "referrals_made": {
      "label": "Referrals made",
      "source": "referral.made",
      "aggregate": "count",
      "having": [">=", "$referrals"],
      "params": { "referrals": "integer" }
    }
  },
  "ui": { "pages": [{ "slot": "nav", "title": "Referrals", "path": "ui/index.html" }] }
}

The plugin reads subscribers, because the Top referrers list shows the referrer’s email. It writes tags for the rewards. onSubscriberDeleted is on, because of the data in leader:<id>.

The code

Make dist/main.js:

// Referrals: each subscriber shares a personal link, and gets a reward at set counts.
//
// A code is the subscriber id in base 36 with a prefix, so an email needs no store call per person
// and a code cannot collide. It is not a secret: a guessed code still needs a real, confirmed signup.
//
// A person counts once they are confirmed. On a double opt-in form subscriber.created comes before
// the confirmation, so the plugin also listens to subscriber.updated. Events arrive at least once
// and these two can arrive together, so store.increment on "credit" lets one call win.
//
// Stored per subscriber, deleted with them:
//   credit     1 once this person was credited to their referrer
//   count      how many people this person referred (a counter)
// Stored per channel:
//   total      all confirmed referrals (a counter)
//   leader:<id>  { id, email, count } for the Top referrers list, deleted with the subscriber.
//                store.list returns 100 rows, so the page shows at most 100 referrers.

const CODE = /^r[0-9a-z]{1,12}$/;
const MAX_LEADERS = 100;

function codeFor(id) {
  return `r${Number(id).toString(36)}`;
}

function idFrom(code) {
  return CODE.test(code ?? "") ? parseInt(code.slice(1), 36) : null;
}

// "3 referral-sticker" on each line gives [{ count: 3, tag: "referral-sticker" }]
function parseRewards(text) {
  return String(text ?? "").split("\n").flatMap((line) => {
    const match = line.trim().match(/^(\d{1,6})\s+(\S.{0,99})$/);
    return match ? [{ count: Number(match[1]), tag: match[2].trim() }] : [];
  });
}

function linkFor(baseUrl, id) {
  const url = new URL(baseUrl);
  url.searchParams.set("ref", codeFor(id));
  return url.toString();
}

async function creditReferrer(data, ctx) {
  const referrerId = idFrom(data.signup_ref);
  const personId = data.subscriber_id;
  if (!referrerId || !data.confirmed_at || !personId || referrerId === personId) return;

  // The referrer must be a real, active person in this channel. A not_found answer ends here.
  let referrer;
  try {
    referrer = await ctx.broadcast.subscribers.get(referrerId);
  } catch (error) {
    if (error.code === "not_found") return;
    throw error;
  }
  if (!referrer.is_active) return;

  if ((await ctx.store.forSubscriber(personId).increment("credit")) !== 1) return;

  await ctx.store.increment("total");
  const count = await ctx.store.forSubscriber(referrerId).increment("count");
  await ctx.broadcast.events.record({
    name: "referral.made",
    subscriber_id: referrerId,
    properties: { referred_id: personId },
    idempotency_key: `referral-${personId}`,
  });
  await ctx.store.set(`leader:${String(referrerId)}`, { id: referrerId, email: referrer.email, count });
  const earned = parseRewards(ctx.settings.get("rewards")).filter((reward) => reward.count === count);
  for (const reward of earned) await giveReward(ctx, referrerId, reward.tag);
}

// The credit is already saved, and an event retry stops at the credit check, so a refused tag must
// not throw. Broadcast refuses a tag that is a topic: a topic is consent.
async function giveReward(ctx, subscriberId, tag) {
  try {
    await ctx.broadcast.tags.add({ subscriber_id: subscriberId, tags: [tag] });
  } catch (error) {
    if (error.code !== "forbidden") throw error;
    ctx.log.warn(`Reward tag "${tag}" was refused: ${error.message}`);
  }
}

export default definePlugin({
  async onEvent(event, ctx) {
    await creditReferrer(event.data ?? {}, ctx);
  },

  mergeTags: {
    async referral_code({ subscribers }) {
      return Object.fromEntries(subscribers.map((person) => [person.id, codeFor(person.id)]));
    },

    async referral_link({ subscribers }, ctx) {
      const baseUrl = ctx.settings.get("form_url");
      if (!baseUrl) return {};
      return Object.fromEntries(subscribers.map((person) => [person.id, linkFor(baseUrl, person.id)]));
    },

    async referral_count({ subscribers }, ctx) {
      const counts = await Promise.all(subscribers.map((person) => ctx.store.forSubscriber(person.id).get("count")));
      return Object.fromEntries(subscribers.map((person, index) => [person.id, String(counts[index] ?? 0)]));
    },
  },

  async onSubscriberDeleted({ subscriber_id }, ctx) {
    await ctx.store.delete(`leader:${String(subscriber_id)}`);
  },

  async pageData(_request, ctx) {
    const rows = await ctx.store.list("leader:");
    const leaders = rows.map((row) => row.value).sort((a, b) => b.count - a.count).slice(0, MAX_LEADERS);
    return {
      leaders,
      rewards: parseRewards(ctx.settings.get("rewards")),
      configured: Boolean(ctx.settings.get("form_url")),
      total: (await ctx.store.get("total")) ?? 0,
      capped: rows.length >= MAX_LEADERS,
    };
  },
});

A code needs no storage

codeFor turns the subscriber ID into text, and idFrom turns it back. So a merge tag can make a link for 100 people in one go, with no calls to Broadcast.

A code is not a secret, and it does not need to be. Someone who guesses a code still needs a real, confirmed signup to count.

Check before you count

creditReferrer stops early unless all these are true:

  • The code is valid.
  • The new person is confirmed.
  • The person is not their own referrer.
  • The referrer exists and is active. subscribers.get throws not_found for a code that points at nobody, and the plugin handles that.

Count each person once

This is the most important line:

if ((await ctx.store.forSubscriber(personId).increment("credit")) !== 1) return;

increment is atomic and gives you the new value. The first call for a person gets 1, and it goes on. Any other call gets 2 or more, and it stops. Two events can arrive at the same moment, and only one wins. A get followed by a set cannot promise this.

Give the reward

parseRewards reads the rewards setting, where each line is a count and a tag. The check reward.count === count means that each reward is given once, at the moment the count reaches it.

To send the reward, make a sequence that starts from that tag.

A reward cannot use a tag that is a topic, because a topic is consent. Broadcast refuses that tag with forbidden. giveReward catches the refusal and logs it, so the referral still counts.

referral_link and referral_code use no store calls, so they are fast. referral_count makes one store call for each person. That is fine in a batch of 100, but do not copy that pattern for slow work.

Clean up

count and credit live in forSubscriber, so they are deleted with the person. The leader:<id> row is in the shared store and holds an email, so onSubscriberDeleted deletes it.

The page

The page is built with Signal. ui/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Referrals</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <sg-page title="Referrals" description="Each subscriber shares a personal link and earns rewards.">
      <sg-banner id="notice" tone="warning" title="One step left" hidden>Enter the signup page address in this plugin's settings. Referral links are empty until then.</sg-banner>
      <sg-grid columns="2">
        <sg-card><sg-stat id="total" label="Confirmed referrals" value="0"></sg-stat></sg-card>
        <sg-card><sg-stat id="people" label="People who referred someone" value="0"></sg-stat></sg-card>
      </sg-grid>
      <sg-card title="Put this in an email">
        <sg-copy value="{{ plugins.local_referrals.referral_link }}" label="Copy link tag"></sg-copy>
        <sg-copy value="{{ plugins.local_referrals.referral_count }}" label="Copy count tag"></sg-copy>
      </sg-card>
      <sg-card title="Rewards" description="Set these in this plugin's settings. A sequence can start from the tag.">
        <sg-list id="rewards"></sg-list>
      </sg-card>
      <sg-card title="Top referrers" flush>
        <sg-table id="leaders" empty-title="No referrals yet" empty-text="People appear here when a person they referred confirms."></sg-table>
      </sg-card>
      <p class="sg-help" id="capped" hidden>This list shows 100 referrers, not always the top 100.</p>
    </sg-page>
  </body>
</html>

ui/app.js:

// The Referrals page, built from the Signal kit (/plugin-kit/v1/). All text goes in as text.
const byId = (id) => document.getElementById(id);
const leaders = byId("leaders");
leaders.columns = [
  { key: "email", label: "Subscriber" },
  { key: "count", label: "Referrals", align: "right", sortable: true },
];

try {
  const data = await sg.pageData();
  byId("notice").hidden = data.configured;
  byId("capped").hidden = !data.capped;
  byId("total").value = String(data.total);
  byId("people").value = String(data.leaders.length);
  leaders.rows = data.leaders;
  byId("rewards").items = data.rewards.length
    ? data.rewards.map((reward) => ({ title: `${reward.count} referrals`, meta: reward.tag }))
    : [{ title: "No rewards set", description: "Add them in this plugin's settings, for example: 3 referral-sticker" }];
} catch (error) {
  sg.toast(`The page could not load: ${error.message}`, { tone: "danger" });
  leaders.rows = [];
}

The Referrals page with two stats, a card with two merge tags to copy, a list of rewards and the start of a Top referrers table

Here is what to notice:

  1. The stats already exist in the HTML. The script sets value, rows and items, and each component redraws itself.
  2. byId("notice").hidden shows the banner only when a setting is missing.
  3. The catch block sets leaders.rows = [], so that the table does not stay in its loading state.

Pack and install

zip -r -X ../referrals-1.0.0.broadcast-plugin manifest.json dist ui

Upload it, review it and deploy it. In the channel, fill in the settings:

Setting Example
Signup page address https://example.com/join
Rewards 3 referral-sticker

The address must be a page with a Broadcast form. Then turn the plugin on.

Use it

  1. Put the link in an email:
<a href="{{ plugins.local_referrals.referral_link }}">Share your link</a>
  1. Send it to a small segment with your own address in it. The link has your code in it, such as ?ref=r1vc5e.
  2. Open the link in a private window and sign up with a second test address.
  3. If the form uses double opt-in, click the link in the confirmation email.
  4. Open Plugins in the sidebar, then Referrals.

You should see your first address in the list of top referrers, with a count of 1. When the count reaches a reward, the tag appears on that subscriber.

If the count stays at 0, check these:

  • Is the second address new? An address that already exists keeps no code.
  • Did the person confirm?
  • Does the form page keep ?ref= in its address?

Things to know

  • A code can be shared anywhere. A referral proves a confirmed signup, not a real friend. For a reward with real value, check for odd patterns before you pay out.
  • The link needs a real send. Plugin hooks do not run in test sends or transactional emails.
  • The page shows up to 100 referrers, because store.list returns at most 100 rows.

Try it yourself

Last updated

Was this page helpful?

Thanks for your feedback!

Thanks for letting us know. We'll work on improving this page.