Currency

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:

  1. Program config at /api/v1/referrals/program — auth-required. One program per merchant; you configure reward sizes, attribution window, expiry.
  2. 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.
  3. 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 null on 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.

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.

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.

  • voidedfalse (with clawedBack: false) when no attribution matches the session or it isn't in rewarded state; otherwise true.
  • clawedBacktrue only when neither reward code had been redeemed, in which case both are set active: false. If either side already redeemed, clawedBack is false, the redeemed code is left active, and the attribution is voided with voidReason: "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.
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

CurrencyRupiah is paid by transfer or QRIS; US dollars settle through PayPal.