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:
- Merchant CRUD at
/api/v1/discount-codes— auth-required, the dashboard or your own backend manages codes here. - Storefront validation at
/api/v1/discount-codes/validate— no auth, the storefront previews what the code would discount before the buyer confirms. - Redemption commit at
/api/v1/discount-codes/redeem— called from a payment-success webhook once payment clears. Idempotent oncheckoutSessionId.
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
- Referrals — programmatic discount issuance keyed on word-of-mouth.
- Abandoned cart — uses a
discountCodeIdto sweeten the recovery email. discount_code.redeemedevent — the wire format for the redemption signal.