Currency

Discount codes

A discount code is a merchant-defined string buyers type into checkout to lower their total. Ripllo owns the entire code lifecycle — creation, validation, redemption tracking, expiry, per-customer caps — and exposes three distinct surfaces:

  1. Merchant CRUD at /api/v1/discount-codes — auth-required, the dashboard or your own backend manages codes here.
  2. Storefront validation at /api/v1/discount-codes/validate — partner-signed; previews what the code would discount before the buyer confirms.
  3. Redemption commit at /api/v1/discount-codes/redeem — partner-signed; called from a payment-success webhook once payment clears. Idempotent per workspace on (accountId, checkoutSessionId).

Every request on this page is signed, with the one exception noted below — see Authentication for the HMAC recipe. /validate and /redeem are partner-authenticated: the caller signs with Ripllo-HMAC-SHA256 and names the merchant in X-Ripllo-On-Behalf-Of. The merchant comes from that signed principal, never from the request body — an accountId in the body must match it (mismatch → 403 ACCOUNT_MISMATCH) and is otherwise overwritten with the authenticated one.

The single exception is GET /applicable/:accountId, which takes no credentials: it only ever returns active, public, merchant-authored codes for the named workspace.

Endpoints

Method Path Purpose
POST /api/v1/discount-codes Create a code
GET /api/v1/discount-codes List codes
GET /api/v1/discount-codes/:id Retrieve a code
PATCH /api/v1/discount-codes/:id Update a code
DELETE /api/v1/discount-codes/:id Archive a code
POST /api/v1/discount-codes/validate Validate a code (partner-signed)
POST /api/v1/discount-codes/redeem Redeem a code (partner-signed)
GET /api/v1/discount-codes/applicable/:accountId List applicable public codes (storefront, no auth)

Create a code

POST /api/v1/discount-codes

Creates a discount code in the calling merchant's workspace. The code field is the literal string buyers will type — uppercase by convention but matched case-insensitively at validation time.

Request body

Field Type Required Notes
code string (1–50) yes The literal code, e.g. WELCOME10. Must be unique within the workspace.
description string (≤500) no Internal label for the dashboard. Never shown to buyers.
type enum yes One of percent, fixed, shipping_percent, shipping_fixed. See Discount types.
value integer yes The size of the discount. For percent/shipping_percent, this is a whole percent from 1 to 100 (so 10 = 10%); anything outside that range is rejected with 400 INVALID_VALUE. For fixed/shipping_fixed, it's the absolute amount in the smallest currency unit (cents for USD/EUR, rupiah for IDR).
currency string (ISO 4217, length 3) yes The currency this code applies to. Cart validation rejects the code when the cart currency differs.
scope enum no cart (default), products, or tags. Determines what subset of the cart the discount applies to.
productIds string[] no Required when scope = "products". The merchant's product IDs (as seen by Storlaunch). Ignored for other scopes.
tagFilter string[] no Required when scope = "tags". Items whose tag set intersects this list qualify for the discount.
minPurchaseAmount integer no Subtotal floor (smallest currency unit) before the code is eligible. null means no minimum.
maxUsesTotal integer no Hard global usage cap. After this many successful redemptions the code stops validating. null is unlimited.
maxUsesPerCustomer integer no Per-customer cap. Counted on customerId at redeem time; anonymous redemptions don't count toward any customer.
startsAt ISO 8601 no Code is inactive before this time. null means "valid immediately".
expiresAt ISO 8601 no Code stops validating after this time. null means "no expiry".
active boolean no Defaults to true. Pass false to create the code paused.
public boolean no Defaults to false. When true, the code appears in /applicable/:accountId for any storefront that asks.
marketingCampaignId string | null no Attach the code to a marketing campaign for roll-up reporting. null (the default) leaves it standalone. A campaign that isn't in the caller's workspace returns 400 VALIDATION with CAMPAIGN_NOT_IN_ACCOUNT.

Response — 201 Created

{
  "data": {
    "id": "clx2k9f0a0000v8pq7h3m1abc",
    "accountId": "acc_01HX9C2K3M4N5P6Q7R8S9T0V1W",
    "code": "WELCOME10",
    "description": "Onboarding code for new newsletter signups",
    "type": "percent",
    "value": 10,
    "currency": "IDR",
    "scope": "cart",
    "productIds": [],
    "tagFilter": [],
    "minPurchaseAmount": null,
    "maxUsesTotal": null,
    "maxUsesPerCustomer": 1,
    "redemptionCount": 0,
    "source": "manual",
    "sourceRefId": null,
    "marketingCampaignId": null,
    "startsAt": null,
    "expiresAt": "2026-06-30T23:59:59.000Z",
    "active": true,
    "public": true,
    "createdAt": "2026-05-13T10:42:00.123Z",
    "updatedAt": "2026-05-13T10:42:00.123Z"
  },
  "error": null,
  "meta": { "requestId": "req_01HX...", "timestamp": "2026-05-13T10:42:00.124Z" }
}

Errors

Status error.code When
400 VALIDATION Field shape wrong, unknown enum, missing required field, or marketingCampaignId not in this workspace (CAMPAIGN_NOT_IN_ACCOUNT).
400 INVALID_VALUE value isn't a positive integer, or a percent/shipping_percent value is outside 1100.
400 INVALID_SCOPE scope = "products" with no productIds, or scope = "tags" with no tagFilter.
409 CODE_EXISTS A code with that exact string already exists in this workspace.
403 NO_ACCOUNT Caller's token has no accountId claim.

Examples

// Node
import { RiplloClient } from '@forjio/ripllo-node';
const ripllo = new RiplloClient({ keyId: process.env.RIPLLO_KEY_ID, secret: process.env.RIPLLO_KEY_SECRET });

const code = await ripllo.discountCodes.create({
  code: 'WELCOME10',
  type: 'percent',
  value: 10,               // 10% — whole percent, 1–100
  currency: 'IDR',
  scope: 'cart',
  maxUsesPerCustomer: 1,
  expiresAt: '2026-06-30T23:59:59Z',
  public: true,
});
# Python
from ripllo import Ripllo
ripllo = Ripllo(key_id=os.environ['RIPLLO_KEY_ID'], secret=os.environ['RIPLLO_KEY_SECRET'])

code = ripllo.discount_codes.create(
    code='WELCOME10',
    type='percent',
    value=10,
    currency='IDR',
    max_uses_per_customer=1,
    expires_at='2026-06-30T23:59:59Z',
    public=True,
)
// Go
import ripllo "github.com/hachimi-cat/ripllo-go"

client := ripllo.New(os.Getenv("RIPLLO_KEY_ID"), os.Getenv("RIPLLO_KEY_SECRET"))
code, err := client.DiscountCodes.Create(ctx, &ripllo.DiscountCodeCreateParams{
    Code:               "WELCOME10",
    Type:               "percent",
    Value:              10,
    Currency:           "IDR",
    MaxUsesPerCustomer: ripllo.Int(1),
    Public:             ripllo.Bool(true),
})
# curl (with the ripllo_curl helper from /docs/api/authentication)
ripllo_curl POST '/api/v1/discount-codes' \
  '{"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","public":true}'

List codes

GET /api/v1/discount-codes

Returns codes in the workspace, newest first. Cursor-paginated.

Query parameters

Param Type Default Notes
limit integer 50 Page size. Clamped to [1, 100].
cursor string Opaque cursor returned in nextCursor of a previous response.
active boolean When set, filters to only active (or only inactive) codes.

Response — 200 OK

{
  "data": {
    "items": [ /* DiscountCode objects */ ],
    "total": 42,
    "nextCursor": "clx2k9f0a0000v8pq7h3m1abc",
    "hasMore": true
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

total counts the same filter as items — pass active=true and total is the number of active codes, not the workspace total. Only when active is omitted is it the workspace-wide count.

let cursor;
do {
  const page = await ripllo.discountCodes.list({ limit: 100, cursor, active: true });
  for (const c of page.items) handle(c);
  cursor = page.nextCursor;
} while (cursor);

Retrieve a code

GET /api/v1/discount-codes/:id

Returns one code by its ID — an opaque cuid, no type prefix.

const code = await ripllo.discountCodes.get('clx2k9f0a0000v8pq7h3m1abc');
Status error.code When
404 NOT_FOUND No such code in this workspace. Cross-workspace IDs return 404, never 403, to avoid leaking existence.

Update a code

PATCH /api/v1/discount-codes/:id

Partial update. The code field itself is immutable — once minted, the string the buyer types is locked. Everything else (description, expiry, caps, scope, active flag) can be changed in place.

Field Mutable? Notes
code no Sending it is not an error — it is stripped before the update. To "rename", deactivate the existing code and create a new one.
description, type, value, currency, scope, productIds, tagFilter yes
minPurchaseAmount, maxUsesTotal, maxUsesPerCustomer yes Send null to clear. Lowering maxUsesTotal below the current redemptionCount won't roll back past redemptions but will block future ones.
startsAt, expiresAt yes Send null to clear.
active, public yes
marketingCampaignId yes Send null to detach. A campaign outside the caller's workspace returns 400 VALIDATION / CAMPAIGN_NOT_IN_ACCOUNT.
await ripllo.discountCodes.update('clx2k9f0a0000v8pq7h3m1abc', {
  expiresAt: null,        // remove the expiry
  maxUsesTotal: 1000,     // cap at 1000 total redemptions
});
Status error.code When
400 VALIDATION Wrong shape on a known field, or a marketingCampaignId outside this workspace. Unknown fields are silently ignored, not rejected.
400 INVALID_VALUE / INVALID_SCOPE Same rules as create, re-checked against the merged row.
404 NOT_FOUND Code doesn't exist or is in another workspace.

Archive a code

DELETE /api/v1/discount-codes/:id

"Archive" here means deactivate: the row stays in the database (so historical redemptions still reference it) and active flips to false, so the code stops validating (reason: "INACTIVE") and drops out of /applicable/:accountId. There is no archivedAt column and no separate archived state.

Deactivated codes are not hidden from the default listing — GET /api/v1/discount-codes with no active param still returns them. Pass ?active=true to get only live codes.

There is no hard-delete. If you need to scrub a code that was never used, deactivate it — that keeps it out of any future validation lookup.

Response — 200 OK

{
  "data": { "id": "clx2k9f0a0000v8pq7h3m1abc", "active": false },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}
await ripllo.discountCodes.archive('clx2k9f0a0000v8pq7h3m1abc');

Validate a code

POST /api/v1/discount-codes/validate

Partner-signed. Takes a cart snapshot and returns whether the code applies plus the exact discount amount. Always read-only — does not consume any per-customer or global uses.

This is the endpoint Storlaunch's backend calls when a buyer types into the "promo code" field. Sign the request with Ripllo-HMAC-SHA256 and name the merchant in X-Ripllo-On-Behalf-Of; the merchant is read from that signed principal. Do not call it straight from browser JavaScript — it needs the partner key.

Request body

Field Type Required Notes
accountId string yes Must equal the X-Ripllo-On-Behalf-Of merchant. Sending a different one returns 403 ACCOUNT_MISMATCH; the signed principal wins either way.
code string yes The literal code the buyer typed. Case-insensitive.
subtotal integer yes Cart subtotal in the smallest currency unit. Used to evaluate minPurchaseAmount and to compute percent discounts.
currency string yes ISO 4217. Must match the code's currency or validation fails.
shippingCost integer no Defaults to 0. Only matters for shipping_* discount types.
customerId string no The buyer's Ripllo customer ID (resolved via partner SDK). If supplied, maxUsesPerCustomer is enforced. Anonymous validate is allowed for "show me the discount before I sign in" UX; per-customer caps simply don't apply.
items array no Per-line snapshot for scope = "products" and scope = "tags" evaluation. Each entry: { productId?, price, quantity, tags? }.

Response — 200 OK

{
  "data": {
    "valid": true,
    "discountAmount": 25000,
    "discountShipping": 0,
    "code": {
      "id": "clx2k9f0a0000v8pq7h3m1abc",
      "code": "WELCOME10",
      "type": "percent",
      "value": 10,
      "currency": "IDR",
      "scope": "cart"
    }
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

The cart discount is discountAmount and the shipping discount is discountShipping (not shippingDiscount). code is the full discount code row — every field in the discount code object, abbreviated above. It is present on most failures too, so you can tell the buyer which rule bit; only NOT_FOUND omits it.

When the code doesn't apply, valid is false and reason is one of:

reason Means
NOT_FOUND No code with that string in this workspace.
INACTIVE active is false.
NOT_YET_ACTIVE startsAt is in the future.
EXPIRED expiresAt is in the past.
CURRENCY_MISMATCH Cart currency differs from the code's.
MIN_PURCHASE subtotal is below minPurchaseAmount.
GLOBAL_LIMIT redemptionCount has reached maxUsesTotal.
PER_CUSTOMER_LIMIT This customerId has reached maxUsesPerCustomer.
SCOPE_MISMATCH Nothing in the cart qualifies — no matching product/tag lines, or a shipping_* code against zero shipping.
{
  "data": {
    "valid": false,
    "reason": "MIN_PURCHASE",
    "discountAmount": 0,
    "discountShipping": 0
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}
const result = await ripllo.discountCodes.validate({
  accountId: 'acc_01HX...',   // the on-behalf-of merchant
  code: 'WELCOME10',
  subtotal: 250000,
  currency: 'IDR',
  customerId: 'cus_01HX...',
});
if (result.valid) showPromoApplied(result.discountAmount);

Redeem a code

POST /api/v1/discount-codes/redeem

Partner-signed. Called from the partner platform's payment-success webhook (Storlaunch → Ripllo via the partner SDK), signed with Ripllo-HMAC-SHA256 + X-Ripllo-On-Behalf-Of. Commits a redemption row and increments the code's redemptionCount in the same transaction.

Idempotent on (accountId, checkoutSessionId) — retried webhooks will not double-redeem. The endpoint accepts the redemption regardless of validation state, by design: the partner platform has already collected the buyer's money based on what validate told it, and Ripllo trusts that signal. If the code has since been deactivated or sold out, the redemption is still recorded so reporting reflects what the buyer actually got.

No webhook fires on redemption — see Events.

Request body

Field Type Required Notes
accountId string yes Must equal the X-Ripllo-On-Behalf-Of merchant (mismatch → 403 ACCOUNT_MISMATCH).
discountCodeId string yes The code's ID (opaque cuid).
checkoutSessionId string yes Partner platform's checkout session reference. Idempotency key, scoped to the workspace.
customerId string no The buyer.
appliedAmount integer yes The exact amount discounted at checkout (smallest currency unit).
appliedShipping integer no The exact shipping amount discounted. Defaults to 0.
externalSource string no Recommended: "storlaunch" when the call comes via the Storlaunch partner SDK. Stamped on the redemption row.
externalRef string no The partner's own ID for this purchase (e.g. Storlaunch's order ID). Unique per (accountId, externalSource, externalRef), so it is a second replay guard.
orderGrossIdr integer no Gross order amount in IDR-minor (subtotal + shipping, before discount). Feeds the marketing-campaign revenue roll-up; when omitted the redemption is excluded from that sum rather than estimated.

Response — 200 OK

The response is deliberately tiny — the redemption row itself is not echoed back:

{
  "data": {
    "id": "clx3m1n2o0001v8pq9r4s2def",
    "created": true
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}
Field Type Notes
id string The DiscountRedemption ID (opaque cuid).
created boolean true when this call wrote the row; false when an existing redemption for the same (accountId, checkoutSessionId) was found and nothing was written.

A second call with the same (accountId, checkoutSessionId) returns the same id with created: false and 200 OK, not a 409. This is the contract that lets the partner platform retry safely. The redemption's timestamp column is createdAt; there is no redeemedAt.

List applicable public codes

GET /api/v1/discount-codes/applicable/:accountId

No auth. Returns the merchant's currently-active, public: true, merchant-authored codes filtered to the supplied cart context. Used by the storefront's "available promos" component.

Three filters the caller can't turn off:

  • source = "manual" — auto-issued reward codes (referral referrer/referee rewards) are personal and never surface here.
  • The validity window: startsAt in the past or null, expiresAt in the future or null, and codes that have hit maxUsesTotal are dropped.
  • take: 20 — the query is capped at 20 codes (newest first) before the per-item filtering below, so a merchant with many public codes will not see all of them. There is no pagination.

Path parameter

Param Notes
accountId The merchant whose codes to list.

Query parameters

Param Default Notes
currency IDR Filter to codes whose currency matches.
productId For scope = "products" codes, include only those that list this product.
tags Comma-separated. For scope = "tags" codes, include only those whose tagFilter intersects this list.
subtotal Used to compute estimatedDiscount and to set eligible. Codes whose minPurchaseAmount exceeds it are returned with eligible: false, not dropped — that's what lets the storefront render "spend Rp 50.000 more to use this". Omit it and every code comes back eligible: true with estimatedDiscount: 0.

Response

{
  "data": {
    "items": [
      {
        "code": "WELCOME10",
        "description": "Onboarding code for new newsletter signups",
        "type": "percent",
        "value": 10,
        "scope": "cart",
        "minPurchaseAmount": null,
        "expiresAt": "2026-06-30T23:59:59.000Z",
        "estimatedDiscount": 25000,
        "eligible": true
      }
    ]
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

Each item is a projection, not the full row: no id, no currency, no accountId, no redemptionCount, no maxUsesTotal. estimatedDiscount is what the code would take off the supplied subtotal, and eligible is false when subtotal is below minPurchaseAmount.

description is included. It is the merchant's internal dashboard label and it is rendered by an endpoint that requires no credentials — anyone who knows an accountId can read it. Don't put anything private (margin notes, partner names, internal campaign codenames) in the description of a public: true code.

The discount code object

Field Type Nullable Notes
id string no Opaque cuid (e.g. clx2k9f0a0000v8pq7h3m1abc) — no type prefix, and not time-sortable. Don't pattern-match a dc_-style prefix; you'll reject valid IDs.
accountId string no Owning workspace.
code string no The literal string buyers type. Immutable after create.
description string yes Internal label. Surfaced verbatim by the public /applicable/:accountId endpoint for public: true codes — keep it free of anything private.
type enum no percent, fixed, shipping_percent, shipping_fixed.
value integer no Whole percent 1–100 for *_percent; smallest-unit amount for *_fixed.
currency string no ISO 4217.
scope enum no cart, products, tags.
productIds string[] no For scope = "products".
tagFilter string[] no For scope = "tags".
minPurchaseAmount integer yes Cart-subtotal floor.
maxUsesTotal integer yes Hard global cap.
maxUsesPerCustomer integer yes Per-customerId cap.
redemptionCount integer no Successful redemptions so far. Updated transactionally with the redemption row. (There is no usesCount field.)
startsAt, expiresAt ISO 8601 yes Validity window.
active boolean no Soft on/off. DELETE sets this to false; there is no archivedAt.
public boolean no Visible in /applicable/:accountId.
source string yes "manual" for merchant-authored (the default), "referral_referrer" / "referral_referee" for auto-issued referral rewards. Only "manual" codes appear in /applicable/:accountId.
sourceRefId string yes The row that caused an auto-issued code, e.g. a ReferralAttribution ID.
marketingCampaignId string yes Parent campaign, or null for a standalone code.
createdAt, updatedAt ISO 8601 no

Discount types

Type Effect
percent Take value% off the eligible-line subtotal, rounded down. value = 10 = 10%. Capped to subtotal — never goes below 0.
fixed Subtract value (in smallest currency unit) from the eligible-line subtotal. Capped to subtotal.
shipping_percent Take value% off the shippingCost, rounded down.
shipping_fixed Subtract value from the shippingCost. Capped to the shipping cost.

shipping_* types ignore scope — they only ever affect shipping, regardless of cart contents.

Idempotency and concurrency

The /redeem endpoint is the only mutation that fires under high concurrency in practice (a popular flash sale firing many simultaneous payment-success webhooks). It uses a database-level unique constraint on (accountId, checkoutSessionId) to enforce idempotency, so the same checkout never produces two redemption rows even under retry storms. The constraint is workspace-scoped, so two merchants can reuse the same partner session id without colliding. A second unique on (accountId, externalSource, externalRef) gives partner replays the same protection when they key on their own order ID.

Updates to maxUsesTotal are racy by definition: between the validate call and the redeem call, another buyer can consume the last slot. Ripllo's contract is "redeem always commits" — if redemptionCount ends up exceeding maxUsesTotal because two redeems crossed the boundary at once, both are recorded and the code is then locked from validating further. We don't reject redeems retroactively; that would orphan paid orders.

Events

No discount-code event is emitted today. All four are reserved names — nothing in the redeem transaction (or any other discount-code handler) writes an outbox row, so a consumer subscribing to them receives nothing. Subscribe defensively; for now, branch on the created flag that /redeem returns to you, or poll GET /api/v1/discount-codes.

Event type Would fire on Status
ripllo.discount_code.created.v1 POST /api/v1/discount-codes succeeds. Reserved — not currently emitted.
ripllo.discount_code.updated.v1 PATCH /api/v1/discount-codes/:id succeeds. Reserved — not currently emitted.
ripllo.discount_code.archived.v1 DELETE /api/v1/discount-codes/:id succeeds. Reserved — not currently emitted.
ripllo.discount_code.redeemed.v1 POST /api/v1/discount-codes/redeem commits a new redemption (not on idempotent replays). Reserved — not currently emitted.

See Webhooks for the event envelope and the state of delivery.

Next

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