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 — no auth, the storefront previews what the code would discount before the buyer confirms.
  3. Redemption commit at /api/v1/discount-codes/redeem — called from a payment-success webhook once payment clears. Idempotent on checkoutSessionId.

All merchant requests must be signed — see Authentication for the HMAC recipe. The /validate and /redeem endpoints are unauthenticated by design because every code is namespaced by accountId — passing a wrong accountId returns nothing rather than leaking codes across merchants.

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 (storefront, no auth)
POST /api/v1/discount-codes/redeem Redeem a code (partner platform)
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 basis points out of 10000 (so 1000 = 10%). 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.

Response — 201 Created

{
  "data": {
    "id": "dc_01HXAB7K3M9N2P5QRS8TVWXY3Z",
    "accountId": "acc_01HX9C2K3M4N5P6Q7R8S9T0V1W",
    "code": "WELCOME10",
    "description": "Onboarding code for new newsletter signups",
    "type": "percent",
    "value": 1000,
    "currency": "IDR",
    "scope": "cart",
    "productIds": [],
    "tagFilter": [],
    "minPurchaseAmount": null,
    "maxUsesTotal": null,
    "maxUsesPerCustomer": 1,
    "usesCount": 0,
    "startsAt": null,
    "expiresAt": "2026-06-30T23:59:59.000Z",
    "active": true,
    "public": true,
    "archivedAt": null,
    "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.
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: 1000,             // 10.00%
  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=1000,
    currency='IDR',
    max_uses_per_customer=1,
    expires_at='2026-06-30T23:59:59Z',
    public=True,
)
// Go
import ripllo "github.com/hachimi-cat/saas-ripllo/sdk/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:              1000,
    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":1000,"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": "dc_01HX...",
    "hasMore": true
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

total is the unfiltered count of codes in the workspace; useful for "you have X codes" headers without re-pulling.

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 dc_… ID.

const code = await ripllo.discountCodes.get('dc_01HX...');
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 To "rename", archive 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 usesCount won't roll back past redemptions but will block future ones.
startsAt, expiresAt yes Send null to clear.
active, public yes
await ripllo.discountCodes.update('dc_01HX...', {
  expiresAt: null,        // remove the expiry
  maxUsesTotal: 1000,     // cap at 1000 total redemptions
});
Status error.code When
400 VALIDATION Unknown field or wrong shape.
404 NOT_FOUND Code doesn't exist or is in another workspace.

Archive a code

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

Soft-deletes the code. The row stays in the database (so historical redemptions still reference it) but the code stops validating on the storefront and is hidden from the default listing.

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

await ripllo.discountCodes.archive('dc_01HX...');

Validate a code

POST /api/v1/discount-codes/validate

No auth. Storefront-facing; 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 storefront calls when a buyer types into the "promo code" field. The merchant key isn't needed because every code is scoped to its accountId and the storefront knows which merchant it's serving.

Request body

Field Type Required Notes
accountId string yes The merchant whose codes to look up. Storlaunch resolves the storefront slug to this.
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,
    "shippingDiscount": 0,
    "code": {
      "id": "dc_01HX...",
      "code": "WELCOME10",
      "type": "percent",
      "value": 1000,
      "currency": "IDR",
      "scope": "cart"
    }
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

When the code doesn't apply, valid is false and reason is one of NOT_FOUND, INACTIVE, NOT_STARTED, EXPIRED, WRONG_CURRENCY, MIN_PURCHASE_NOT_MET, MAX_USES_REACHED, CUSTOMER_LIMIT_REACHED, NO_APPLICABLE_ITEMS:

{
  "data": {
    "valid": false,
    "reason": "MIN_PURCHASE_NOT_MET",
    "discountAmount": 0,
    "shippingDiscount": 0
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}
const result = await ripllo.discountCodes.validate({
  accountId: 'acc_01HX...',
  code: 'WELCOME10',
  subtotal: 250000,
  currency: 'IDR',
  customerId: 'cus_01HX...',
});
if (result.valid) showPromoApplied(result.discountAmount);

Redeem a code

POST /api/v1/discount-codes/redeem

No auth. Called from the partner platform's payment-success webhook (Storlaunch → Ripllo via the partner SDK). Commits a redemption row, increments usesCount and the per-customer counter, and emits a discount_code.redeemed outbox event.

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.

Request body

Field Type Required Notes
accountId string yes The merchant.
discountCodeId string yes The dc_… ID.
checkoutSessionId string yes Partner platform's checkout session reference. Idempotency key.
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).

Response — 200 OK

{
  "data": {
    "id": "dcr_01HX...",
    "discountCodeId": "dc_01HX...",
    "checkoutSessionId": "cs_01HX...",
    "appliedAmount": 25000,
    "appliedShipping": 0,
    "customerId": "cus_01HX...",
    "externalSource": "storlaunch",
    "externalRef": "ord_42",
    "redeemedAt": "2026-05-13T10:43:22.140Z"
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

A second call with the same (accountId, checkoutSessionId) returns the same row with 200 OK, not a 409. This is the contract that lets the partner platform retry safely.

List applicable public codes

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

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

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 Drop codes whose minPurchaseAmount exceeds this.

Response

{
  "data": {
    "items": [
      {
        "id": "dc_01HX...",
        "code": "WELCOME10",
        "type": "percent",
        "value": 1000,
        "currency": "IDR",
        "scope": "cart",
        "minPurchaseAmount": null,
        "expiresAt": "2026-06-30T23:59:59.000Z"
      }
    ]
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

Codes are returned sanitized — no internal description, no usesCount, no maxUsesTotal. The storefront only ever sees what's safe to render.

The discount code object

Field Type Nullable Notes
id string no Prefix dc_ + ULID.
accountId string no Owning workspace.
code string no The literal string buyers type. Immutable after create.
description string yes Internal label. Never shown to buyers.
type enum no percent, fixed, shipping_percent, shipping_fixed.
value integer no Basis points 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.
usesCount integer no Successful redemptions so far. Updated transactionally with the redemption row.
startsAt, expiresAt ISO 8601 yes Validity window.
active boolean no Soft on/off.
public boolean no Visible in /applicable/:accountId.
archivedAt ISO 8601 yes Set on DELETE. Archived codes don't validate.
createdAt, updatedAt ISO 8601 no

Discount types

Type Effect
percent Take value/10000 off the eligible-line subtotal. value = 1000 = 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/10000 off the shippingCost.
shipping_fixed Subtract value from the shippingCost.

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.

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 usesCount 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

Event type Fires on Status
ripllo.discount_code.created.v1 POST /api/v1/discount-codes succeeds. Reserved — not currently emitted. Subscribe defensively.
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). Emitted — primary signal for "this code was used".

See Webhooks for the event envelope and delivery contract.

Next