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— partner-signed; previews what the code would discount before the buyer confirms. - 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 1–100. |
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:
startsAtin the past or null,expiresAtin the future or null, and codes that have hitmaxUsesTotalare 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.
descriptionis included. It is the merchant's internal dashboard label and it is rendered by an endpoint that requires no credentials — anyone who knows anaccountIdcan read it. Don't put anything private (margin notes, partner names, internal campaign codenames) in thedescriptionof apublic: truecode.
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
- Referrals — programmatic discount issuance keyed on word-of-mouth.
- Abandoned cart — uses a
discountCodeIdto sweeten the recovery email. discount_code.redeemedevent — the wire format the redemption signal will take once it is emitted.