Referrals
Ripllo's referral program lets a merchant turn existing customers into a referral channel. Once enabled, every customer can claim a unique referral link; sharing it earns the referrer a discount when the referee makes a qualifying purchase, and mints the referee their own welcome discount at the same moment. Both codes are minted together at fulfilment — nothing is issued at signup, so the referee's welcome code lands after their first qualifying purchase completes, not before it.
The resource splits into three surfaces:
- Program config at
/api/v1/referrals/program— auth-required. One program per merchant; you configure reward sizes, attribution window, expiry. - Link minting and resolution at
/api/v1/referrals/links/*— storefront-facing. Issues per-customer codes and resolves them back to the owning customer when a click comes in. - Attribution lifecycle at
/api/v1/referrals/attributions/*— called by the partner platform's webhooks. Records signups, checkout starts, payment success (fulfil reward), and refunds (void).
Every write on surfaces 2 and 3 is a signed partner call: Authorization: Ripllo-HMAC-SHA256 … plus X-Ripllo-On-Behalf-Of: acc_<merchantAccountId> when you hold a ripllo:platform:admin key. The workspace comes from the signed principal, not the body: Ripllo overwrites any accountId you send with the principal's, and rejects a different one with 403 ACCOUNT_MISMATCH. Only the two GET reads (/links/:accountId/:code and /rewards/:accountId/:customerId) are unauthenticated. The cron sweep additionally requires a ripllo:platform:admin key.
Plus a buyer-facing reward list and an internal cron sweep.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/referrals/program |
Get program config |
PUT |
/api/v1/referrals/program |
Upsert program config |
GET |
/api/v1/referrals/stats |
Program stats |
POST |
/api/v1/referrals/links/issue |
Issue or fetch a referral link |
GET |
/api/v1/referrals/links/:accountId/:code |
Resolve a link by its code |
POST |
/api/v1/referrals/links/click |
Record a click |
POST |
/api/v1/referrals/attributions/signup |
Attribute on signup |
POST |
/api/v1/referrals/attributions/checkout-start |
Attribute on checkout start |
POST |
/api/v1/referrals/attributions/fulfill |
Fulfil reward on payment |
POST |
/api/v1/referrals/attributions/void |
Void on refund |
GET |
/api/v1/referrals/rewards/:accountId/:customerId |
List a customer's rewards |
POST |
/api/v1/referrals/sweeps/expire-pending |
Expire stale pending attributions (cron) |
Get program config
GET /api/v1/referrals/program
Returns the program for the calling merchant, or null if the merchant has never configured one. The default response when no program exists is the literal JSON null — not a 404 — so dashboards can render "you haven't set up referrals yet" without an extra request.
{
"data": {
"id": "clw3ka1c70003v8f4x2mn8bq7",
"accountId": "acc_01HX...",
"enabled": true,
"rewardType": "percent",
"referrerValue": 10,
"refereeValue": 5,
"currency": "IDR",
"minPurchaseAmount": 100000,
"rewardExpiryDays": 90,
"attributionWindowDays": 30,
"maxRewardsPerReferrer": null,
"programTerms": "...",
"createdAt": "2026-05-12T10:42:00.000Z",
"updatedAt": "2026-05-13T08:11:00.000Z"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Upsert program config
PUT /api/v1/referrals/program
Creates or updates the merchant's program. The semantics are mixed, so read this table before omitting anything:
- Patch-style (omit → stored value is left untouched on update):
enabled,rewardExpiryDays,attributionWindowDays,marketingCampaignId. - Reset-style (omit → forced back to
nullon update):minPurchaseAmount,maxRewardsPerReferrer,programTerms. - Always required:
rewardType,referrerValue,refereeValue,currency.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
enabled |
boolean | no | Defaults to false on create. A program you create without enabled: true is dormant — see Issue a referral link for what that does to link minting. On update, omitting it leaves the stored value alone. Disabling stops new attributions; existing pending ones still resolve. |
rewardType |
enum | yes | One of percent, fixed, shipping_percent, shipping_fixed. Mirrors discount-code types. |
referrerValue |
integer | yes | Reward size for the referrer when the referee makes a qualifying purchase. For percent and shipping_percent this is a whole percent, 1–100 (10 = 10% off); anything outside that range is 400 INVALID_VALUE. For *_fixed it is the smallest currency unit. |
refereeValue |
integer | yes | Welcome discount minted for the referee, on the same units as referrerValue. Minted at fulfilment alongside the referrer's code — not available for the purchase that triggers it. |
currency |
string | yes | ISO 4217. The currency rewards are denominated in. |
minPurchaseAmount |
integer | no | The referee's purchase must be at least this much (smallest currency unit) before the referrer's reward fulfils. null means no minimum. |
rewardExpiryDays |
integer | no | Defaults to 90. Referrer rewards expire this many days after the referee's qualifying purchase. |
attributionWindowDays |
integer | no | Defaults to 30. Referees who sign up via a link have this long to make their qualifying purchase before the attribution expires. |
maxRewardsPerReferrer |
integer | no | Cap on lifetime rewards earnable per referrer customer. null is unlimited. |
programTerms |
string (≤5000) | no | Markdown program terms shown on the storefront. |
Response — 200 OK
The full program object (same shape as GET).
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
Shape wrong, non-positive values, currency length ≠ 3, marketingCampaignId outside your workspace. |
400 |
INVALID_VALUE |
Percent reward outside 1–100, rewardExpiryDays outside 1–365, or attributionWindowDays outside 1–180. |
There is no transition guard on disabling: setting enabled: false always succeeds, however many pending attributions exist.
await ripllo.referrals.putProgram({
enabled: true, // omit this on create and the program is dormant
rewardType: 'percent',
referrerValue: 10, // 10% off for the referrer
refereeValue: 5, // 5% off for the new customer, minted at fulfilment
currency: 'IDR',
minPurchaseAmount: 100000,
attributionWindowDays: 30,
});
Program stats
GET /api/v1/referrals/stats
Snapshot of program health. The numbers are computed live, in the request — a single aggregate over the workspace's ReferralLink counters. There is no background aggregator and no lag: what you read is exactly what those counters hold right now.
{
"data": {
"totalLinks": 412,
"totalClicks": 1283,
"totalSignups": 87,
"totalRewards": 54,
"attributedRevenue": 1350000,
"conversionRate": 0.0421
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
attributedRevenue is the summed gross of rewarded checkouts in the smallest currency unit, and conversionRate is totalRewards / totalClicks (0 when there have been no clicks). There is no per-status attribution breakdown on this endpoint — pending, expired and voided counts are not exposed.
Issue a referral link
POST /api/v1/referrals/links/issue
Signed partner call. Mints (or returns the existing) referral link for the authenticated workspace and a customerId. Idempotent: the same call always returns the same link object.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
accountId |
string | see note | Must equal the signed principal's workspace. Ripllo overwrites the field with the principal before validating, so omitting it over raw HTTP is fine; a different value is 403 ACCOUNT_MISMATCH. The typed SDKs still declare it required. |
customerId |
string | yes | The customer who will be the referrer. |
Response
{
"data": {
"link": {
"id": "clw3ka2f90004v8f4k5pw2rt1",
"programId": "clw3ka1c70003v8f4x2mn8bq7",
"accountId": "acc_01HX...",
"customerId": "cus_01HX...",
"code": "k7mq3xr9",
"clicks": 0,
"signups": 0,
"rewards": 0,
"revenue": 0,
"createdAt": "2026-05-13T10:42:00.000Z"
}
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
The code is 8 lowercase characters from a no-lookalike alphabet (abcdefghjkmnpqrstuvwxyz23456789 — no i, l, o, 0, 1), unique per workspace. There is no url field: the caller builds the share URL itself from the merchant's storefront domain plus an r/<code> redirect endpoint.
When the program is off
If the merchant has no program, or the program has enabled: false, this endpoint returns 200 OK with {"link": null} — not a 404 and not an error. Since enabled defaults to false on create, a freshly-created program mints nothing until you explicitly enable it. Check for link === null before rendering a share sheet.
Resolve a link by its code
GET /api/v1/referrals/links/:accountId/:code
No auth. Used by the storefront's referral redirect handler. Returns the link record (including the referrer's customerId) so the storefront can record a click and stash the referral context in a cookie before redirecting to the homepage.
| Status | error.code |
When |
|---|---|---|
404 |
NOT_FOUND |
No such code in this workspace. |
Record a click
POST /api/v1/referrals/links/click
Signed partner call. Increments clicks on the link. Called by the storefront redirect handler after resolving the code. Best-effort — failures here don't block the redirect.
Request body
| Field | Required | Notes |
|---|---|---|
accountId |
see note | Must equal the signed principal's workspace; a different value is 403 ACCOUNT_MISMATCH. |
code |
yes | The referral code from the URL. |
Returns { "linkId": "<cuid>", "programId": "<cuid>" } (both opaque cuids, no prefix) — not the link row. Returns null when the code is unknown or the owning program is disabled; the partner-platform integration tolerates both.
Attribute on signup
POST /api/v1/referrals/attributions/signup
Signed partner call. Records that a newly-signed-up customer arrived via a referral link. Creates a ReferralAttribution row in pending and starts the attributionWindowDays countdown. No discount code is minted here — both reward codes are minted later, at fulfilment.
Request body
| Field | Required | Notes |
|---|---|---|
accountId |
see note | Must equal the signed principal's workspace. Ripllo overwrites the field with the principal before validating, so omitting it over raw HTTP is fine; a different value is 403 ACCOUNT_MISMATCH. The typed SDKs still declare it required. |
refereeCustomerId |
yes | The new customer's opaque partner-side customer ID. |
refereeEmail |
yes | Used only for the self-referral guard — it is compared case-insensitively against referrerEmail and the attribution is skipped when they match. Ripllo sends no email from this endpoint. |
referrerEmail |
no | Optional partner-side hint for that same guard. It is not inferred from the link when omitted; without it the email check simply doesn't run. |
linkCode |
yes | The referral code (8 lowercase chars). |
externalSource |
no | Recommended: "storlaunch" for partner-SDK calls. Stamped on the attribution row. |
externalRef |
no | Partner's own customer ID. Defaults to refereeCustomerId. |
The endpoint is idempotent on (accountId, refereeCustomerId) — a buyer who signs up twice via different referral links is attributed to the first one only. Every "not eligible" outcome (unknown code, disabled program, self-referral, replay) answers 200 OK with {"attribution": null} rather than an error.
await ripllo.referrals.attributeOnSignup({
accountId: 'acc_01HX...', // must match the signed principal
refereeCustomerId: 'cus_01HX...',
refereeEmail: 'bob@example.com',
linkCode: 'k7mq3xr9',
externalSource: 'storlaunch',
externalRef: 'storlaunch_cust_983',
});
Attribute on checkout start
POST /api/v1/referrals/attributions/checkout-start
Signed partner call. Stamps the pending attribution with a specific checkout session so the subsequent payment-success webhook can find it.
| Field | Required | Notes |
|---|---|---|
accountId |
see note | Must equal the signed principal's workspace; a different value is 403 ACCOUNT_MISMATCH. |
customerId |
yes | The referee. |
checkoutSessionId |
yes | Partner platform's session ID. |
The attribution is looked up by (accountId, customerId), not by the session ID — there is one pending attribution per referee, and this call writes the session onto it. So a second call with a different checkoutSessionId for the same customer overwrites the stamp, unless that session ID is already stamped on another attribution (qualifyingCheckoutSessionId is globally unique), in which case the first stamp is kept.
Returns { "stamped": true, "attributionId": "rat_…" } on success. Returns { "stamped": false } when no attribution exists for that customer, when it is no longer pending, or when its window has already expired — all fine and expected for non-referral checkouts. On the unique-collision path you get { "stamped": false, "attributionId": "rat_…" }.
Fulfil reward on payment
POST /api/v1/referrals/attributions/fulfill
Signed partner call. Called from the partner platform's payment-success webhook. Transitions the attribution from pending to rewarded and mints two discount codes in one transaction — the referrer's (source: referral_referrer, code shaped REF-XXXX-YYYY) and the referee's welcome code (source: referral_referee, code shaped WELCOME-XXXXXX). Both are real dc_… rows with maxUsesTotal = 1, maxUsesPerCustomer = 1 and the program's rewardExpiryDays.
Request body
| Field | Required | Notes |
|---|---|---|
checkoutSessionId |
yes | Looks up the attribution stamped at checkout-start. |
status |
yes | Partner-side session status. The only accepted value is "completed" — anything else (including "succeeded") short-circuits to { "issued": false, "reason": "session_not_completed" } and no reward is ever minted. |
currency |
yes | Cart currency. Must match the program currency or the attribution is voided. |
amount |
yes | Cart subtotal (smallest currency unit). Compared against minPurchaseAmount. |
Response
{
"data": {
"issued": true,
"referrerCodeId": "dc_01HX...",
"refereeCodeId": "dc_01HX..."
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Every non-issuing outcome is { "issued": false, "reason": "<why>" } with no IDs at all — including a retried webhook, which reports already_fulfilled and returns nothing else. The reason values are:
reason |
Meaning | Side effect |
|---|---|---|
session_not_completed |
status was not "completed". |
None. |
no_attribution |
No attribution carries this checkoutSessionId. |
None. |
already_fulfilled |
The attribution is no longer pending (retry, or already voided/expired). |
None. |
expired |
The attribution's window elapsed. | Attribution set to expired. |
program_disabled |
The program was switched off between signup and payment. | Attribution set to voided. |
currency_mismatch |
Cart currency ≠ program currency. | Attribution set to voided. |
min_purchase |
amount below the program's minPurchaseAmount. |
None — the attribution stays pending and can still fulfil on a later qualifying session. |
max_rewards_per_referrer |
The referrer's link already hit the program cap. | Attribution set to voided. |
Void on refund
POST /api/v1/referrals/attributions/void
Signed partner call. Called when a payment is refunded after fulfilment. Marks the attribution voided and deactivates the minted reward codes that have not been redeemed yet.
Request body
| Field | Required | Notes |
|---|---|---|
checkoutSessionId |
yes | Resolves to the attribution. |
Response
{
"data": {
"voided": true,
"clawedBack": true
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Only two fields come back — there is no attributionId and no rewardArchived.
voided—false(withclawedBack: false) when no attribution matches the session or it isn't inrewardedstate; otherwisetrue.clawedBack—trueonly when neither reward code had been redeemed, in which case both are setactive: false. If either side already redeemed,clawedBackisfalse, the redeemed code is left active, and the attribution is voided withvoidReason: "refunded_after_use"so reports surface the leak. (The unredeemed sibling code is still deactivated.)
Existing redemptions are never reversed — that would require reversing the other customer's purchase too, which is outside Ripllo's scope. Voiding is forward-only.
List a customer's rewards
GET /api/v1/referrals/rewards/:accountId/:customerId
No auth. Returns the reward codes minted for this customer — both sides: codes they earned as a referrer and codes they received as a referee. Only attributions in rewarded state are listed, newest first. The storefront uses this to render the "your rewards" page in the buyer dashboard.
{
"data": {
"items": [
{
"role": "referrer",
"attributionId": "rat_01HX...",
"code": "REF-8F2C-K7MQ",
"discountType": "percent",
"value": 10,
"currency": "IDR",
"expiresAt": "2026-08-13T10:42:00.000Z",
"redeemed": false,
"active": true,
"earnedAt": "2026-05-15T10:42:00.000Z"
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
There is no id on these items — key them on attributionId + role, or on code. role is "referrer" or "referee"; discountType (not type) carries the discount kind. active: false means the code was clawed back by a refund.
Expire stale pending attributions
POST /api/v1/referrals/sweeps/expire-pending
Platform-admin only. Requires a signed key carrying the ripllo:platform:admin scope — the sweep is cross-tenant (it walks every workspace), so a merchant key gets 403. Marks every pending attribution whose expiresAt is in the past as expired, stamping voidedAt and voidReason: "window_elapsed". Returns { expired: <count> }.
Nothing in Ripllo calls this on a timer. There is no scheduler, cron entry or worker in the service that drives the sweep — the caller owns it. If you rely on automatic expiry, wire this endpoint into your own scheduler; until something calls it, stale attributions stay pending indefinitely (they are still rejected at fulfilment time, which checks expiresAt directly, so an un-swept attribution cannot pay out). It's idempotent and bounded by row count, so calling it often is harmless.
The objects
ReferralProgram
| Field | Type | Nullable | Notes |
|---|---|---|---|
id |
string | no | Opaque cuid — no prefix. Treat as an opaque string; do not parse or validate its shape. |
accountId |
string | no | Owning workspace. Unique — one program per merchant. |
enabled |
boolean | no | Whether the program mints links and accepts new attributions. Defaults to false. |
rewardType |
enum | no | percent/fixed/shipping_*. |
referrerValue, refereeValue |
integer | no | Reward sizes. |
currency |
string | no | ISO 4217. |
minPurchaseAmount |
integer | yes | Floor for the referee's qualifying purchase. |
rewardExpiryDays |
integer | no | How long the minted reward code lives. |
attributionWindowDays |
integer | no | How long a pending attribution stays valid. |
maxRewardsPerReferrer |
integer | yes | Lifetime cap. |
programTerms |
string | yes | Markdown. |
marketingCampaignId |
string | yes | Optional parent marketing campaign. |
ReferralLink
| Field | Type | Notes |
|---|---|---|
id |
string | Link ID. |
programId |
string | Owning ReferralProgram. |
accountId |
string | Owning workspace. |
customerId |
string | The referrer. Globally unique — one link per customer. |
code |
string | 8 lowercase chars from abcdefghjkmnpqrstuvwxyz23456789. Unique per workspace. |
clicks |
integer | Best-effort click counter. |
signups |
integer | Attributions created from this link. |
rewards |
integer | Fulfilled rewards; decremented when a fulfilment is voided. |
revenue |
integer | Attributed gross revenue, smallest currency unit. |
createdAt |
ISO 8601 |
There is no url column — build the share URL from the storefront domain plus r/<code>.
ReferralAttribution
| Field | Type | Nullable | Notes |
|---|---|---|---|
id |
string | no | Attribution ID. |
programId |
string | no | Owning ReferralProgram. |
accountId |
string | no | Owning workspace. |
linkId |
string | no | The link that earned it. |
referrerCustomerId |
string | no | Looked up from the link. |
refereeCustomerId |
string | no | From the signup call. Unique per workspace. |
status |
enum | no | pending, rewarded, voided, expired. The success state is rewarded — there is no fulfilled value, and filtering on one returns zero rows. |
referrerRewardCodeId |
string | yes | The referrer's minted code. Set on fulfilment. |
refereeRewardCodeId |
string | yes | The referee's welcome code. Set on fulfilment. Two separate columns — there is no single rewardDiscountCodeId. |
qualifyingCheckoutSessionId |
string | yes | Set by checkout-start. Globally unique. |
expiresAt |
ISO 8601 | no | When the pending attribution times out. |
clickedAt |
ISO 8601 | no | Row creation time. |
signedUpAt |
ISO 8601 | yes | Set by the signup call. |
rewardedAt |
ISO 8601 | yes | Set on fulfilment. |
voidedAt |
ISO 8601 | yes | Set on void and on expiry — there is no expiredAt column. |
voidReason |
string | yes | refunded, refunded_after_use, expired, program_disabled, currency_mismatch, max_rewards_per_referrer, or window_elapsed for the cron sweep. |
externalSource, externalRef |
string | yes | Partner-platform anchors. |
Events
No referral event is emitted today. The referral service writes no outbox rows at all, so all four types below are reserved shapes rather than live events. Poll the endpoints above (or have your payment webhook call fulfil synchronously) rather than waiting on a callback.
| Event type | Would fire on | Status |
|---|---|---|
ripllo.referral.created.v1 |
POST /referrals/attributions/signup succeeds (new attribution). |
Reserved — not currently emitted. |
ripllo.referral.fulfilled.v1 |
POST /referrals/attributions/fulfill transitions to rewarded. |
Reserved — not currently emitted. |
ripllo.referral.voided.v1 |
POST /referrals/attributions/void transitions to voided. |
Reserved — not currently emitted. |
ripllo.referral.expired.v1 |
Cron sweep transitions an attribution to expired. |
Reserved — not currently emitted. |
Next
- Discount codes — the underlying primitive that powers fulfilment rewards.
referral.createdevent — the reserved payload shape for new attributions.- Webhooks — envelope + delivery contract.