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.
Design decisions
Section titled “Design decisions”| Decision | This example | Why it matters |
|---|---|---|
| When is the code applied? | At signup, before any purchase is possible | If attribution can happen after earlier orders, “first purchase after attribution” is no longer “first purchase” |
| Qualifying event | order_completed, sent by your backend with the buyer as userId | The rule’s default reward recipient is the event’s userId |
| One-time guard | Customer metadata flag referralRewarded | Referral records have no “rewarded” state, so the program needs its own marker |
| Rewards from system codes | None (no referrer) | The rule requires a referrerId |
1. Attribute at signup
Section titled “1. Attribute at signup”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" }2. Register the resources the rule uses
Section titled “2. Register the resources the rule uses”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.
3. Create the rule
Section titled “3. Create the rule”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_completedevent resource makes the rule run only for that event type. - Condition 1 passes only for referees of a user-owned code. Keep it: a
rewardwhoseuserIdresolves 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_useraction sets it in the same run, so later orders don’t pay again. A missing profile also passes (the resource resolves to an_errorobject) and is then created byupdate_user. - The first
rewardhas nouserId, so it goes to the event’s user (the referee).
4. Send the qualifying event
Section titled “4. Send the qualifying event”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.
Verify
Section titled “Verify”- Test the conditions with no side effects:
POST /projects/{projectId}/rules/testwith theconditionsabove and a hand-madecontext, 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. - Record both starting balances:
GET /projects/{projectId}/users/cus_bob/walletand…/cus_alice/wallet. - Send one
order_completedfor the referee, then checkGET /projects/{projectId}/users/cus_bob/eventsfor the workflow and its action results. - Both balances increased by exactly 500, each with an
earntransaction carrying the description above. GET /projects/{projectId}/users/cus_bobshowsreferralRewardedin its metadata.- Send a second order: no new transactions. Send an order for a customer who was not referred: no transactions.
Pitfalls
Section titled “Pitfalls”- 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_completedonce per customer for the first order, or carry anisFirstOrderflag 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 ascus_bobbut purchasing as[email protected]never qualifies.