Skip to content

Referral Programs

A referral program in Storelayer has two halves. Attribution uses the referral module: a code is applied and a referral record links referee to referrer. Rewards use the rules engine: a rule on your qualifying event reads the referral record and pays both sides. This guide builds this program:

When a referred customer completes their first purchase, the referee and the referrer each receive 500 points.

DecisionThis exampleWhy it matters
When is the code applied?At signup, before any purchase is possibleIf attribution can happen after earlier orders, “first purchase after attribution” is no longer “first purchase”
Qualifying eventorder_completed, sent by your backend with the buyer as userIdThe rule’s default reward recipient is the event’s userId
One-time guardCustomer metadata flag referralRewardedReferral records have no “rewarded” state, so the program needs its own marker
Rewards from system codesNone (no referrer)The rule requires a referrerId

The app creates the referee’s profile and applies the code (see integrating referrals):

POST /projects/{projectId}/users { "userId": "cus_bob", "firstName": "Bob", "lastName": "Lee" }
POST /projects/{projectId}/referrals/apply { "code": "K7M2QX9A", "refereeId": "cus_bob" }

A rule can only use resources registered in the project. Create each with POST /projects/{projectId}/resources and keep the returned ids.

The trigger event. Its key is the event type, and event resources must declare type and payload fields:

{
"key": "order_completed",
"name": "Order completed",
"type": "event",
"fields": [
{ "key": "type", "label": "Type", "type": "string" },
{ "key": "payload", "label": "Payload", "type": "object" }
]
}

The referee’s referral record (empty items if they were never referred):

{
"key": "referral",
"name": "Referral of event user",
"type": "internal",
"config": {
"toolName": "referral.list_referrals",
"params": { "refereeId": "{{ $('event').userId }}", "limit": 1 }
},
"dependsOn": ["event"]
}

The referee’s profile, for the one-time flag:

{
"key": "customer",
"name": "Event customer",
"type": "internal",
"config": {
"toolName": "external_users.get_user",
"params": { "userId": "{{ $('event').userId }}" }
},
"dependsOn": ["event"]
}

Skip any resource that already exists with the same key and type; check with GET /projects/{projectId}/resources.

POST /projects/{projectId}/rules

{
"name": "Referral bonus on first purchase",
"resources": [
{ "id": "<order_completed resource id>", "key": "order_completed", "type": "event", "value": {} },
{ "id": "<referral resource id>", "key": "referral", "type": "internal", "value": {} },
{ "id": "<customer resource id>", "key": "customer", "type": "internal", "value": {} }
],
"conditions": {
"combinator": "AND",
"conditions": [
{ "leftValue": "{{ $('referral').items[0].referrerId }}", "operator": "exists" },
{ "leftValue": "{{ $('customer').metadata.referralRewarded }}", "operator": "notExists" }
]
},
"actions": [
{
"type": "reward",
"config": { "assetType": "points", "amount": 500, "description": "Referral bonus: welcome purchase" }
},
{
"type": "reward",
"config": {
"assetType": "points",
"amount": 500,
"userId": "{{ $('referral').items[0].referrerId }}",
"description": "Referral bonus: friend's first purchase"
}
},
{ "type": "update_user", "config": { "metadata": { "referralRewarded": true } } }
]
}

How it works:

  • The order_completed event resource makes the rule run only for that event type.
  • Condition 1 passes only for referees of a user-owned code. Keep it: a reward whose userId resolves to nothing falls back to the event’s user, so without this check the referee would be paid twice.
  • Condition 2 passes until the flag is set. The update_user action sets it in the same run, so later orders don’t pay again. A missing profile also passes (the resource resolves to an _error object) and is then created by update_user.
  • The first reward has no userId, so it goes to the event’s user (the referee).

POST /ingest/events with the project API key:

{
"type": "order_completed",
"userId": "cus_bob",
"payload": { "orderId": "ord_5001", "amount": 4599, "currency": "USD" }
}

The response { "id": "evt_…", "status": "accepted" } only means the event was queued. See sending events.

  1. Test the conditions with no side effects: POST /projects/{projectId}/rules/test with the conditions above and a hand-made context, e.g. { "event": { "type": "order_completed", "userId": "cus_bob" }, "referral": { "items": [{ "referrerId": "cus_alice" }] }, "customer": { "metadata": {} } }. Then repeat with "items": [] and with "metadata": { "referralRewarded": true }; both must fail.
  2. Record both starting balances: GET /projects/{projectId}/users/cus_bob/wallet and …/cus_alice/wallet.
  3. Send one order_completed for the referee, then check GET /projects/{projectId}/users/cus_bob/events for the workflow and its action results.
  4. Both balances increased by exactly 500, each with an earn transaction carrying the description above.
  5. GET /projects/{projectId}/users/cus_bob shows referralRewarded in its metadata.
  6. Send a second order: no new transactions. Send an order for a customer who was not referred: no transactions.
  • Concurrent first orders. Two orders processed at the same moment can both pass condition 2 before either sets the flag. If customers can place parallel orders, deduplicate qualifying events in your backend (send order_completed once per customer for the first order, or carry an isFirstOrder flag and add it as a condition).
  • Refunds. Rewards are not reversed automatically. Decide whether a refunded first order should claw back points and handle it with an explicit spend.
  • Different reward sizes for referrer and referee only need different amounts. Rewarding only after the referee’s Nth purchase needs a counter (customer metadata or a custom table) instead of a boolean flag.
  • Keep the same userIds across signup, apply, events and wallets. A referee applied as cus_bob but purchasing as bob@example.com never qualifies.