Discount codes
A discount code is the merchant-defined string a buyer types into checkout to lower their total. Ripllo owns the entire lifecycle — creation, validation, redemption tracking, expiry, per-customer caps — and exposes it through ripllo.discountCodes on the Node SDK. The same namespace covers merchant CRUD, the storefront validation preview, and the idempotent redemption commit your payment-success handler calls. For the underlying HTTP surface and the full field tables, see API → Discount codes.
Namespace
ripllo.discountCodes — every method on this namespace:
ripllo.discountCodes.list(params?)
ripllo.discountCodes.get(id)
ripllo.discountCodes.create(input)
ripllo.discountCodes.update(id, patch)
ripllo.discountCodes.archive(id)
ripllo.discountCodes.validate(input)
ripllo.discountCodes.redeem(input)
ripllo.discountCodes.applicable(accountId, params?)
The first five are the merchant CRUD surface (HMAC-signed, scoped to the calling workspace). validate and redeem serve the buyer-facing flow but are also authenticated — sign them like any other call, and name the merchant with onBehalfOf if you're a platform. The accountId you pass in the body must match the authenticated principal, or you get 403 ACCOUNT_MISMATCH. redeem is idempotent on (accountId, checkoutSessionId), which is why your webhook can call it as many times as it wants. applicable is the only genuinely public method — the storefront teaser feed.
Methods
discountCodes.create
Signature. ripllo.discountCodes.create(input): Promise<DiscountCode>
Creates a code in the workspace the API key belongs to (or the one named by onBehalfOf, for platform keys).
The SDK auto-generates an Idempotency-Key header for this method, but it does not make retries safe: Ripllo only folds that header into the HMAC string-to-sign — there is no replay cache, so a retried create is a second create. What protects you here is the (accountId, code) unique constraint: the retry fails with 409 CODE_EXISTS rather than minting a duplicate. Elsewhere the guard is a specific DB constraint too — redeem on (accountId, checkoutSessionId), partner writes on (accountId, externalSource, externalRef).
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, // whole percent → 10%
currency: 'IDR',
scope: 'cart',
maxUsesPerCustomer: 1,
expiresAt: '2026-06-30T23:59:59Z',
public: true,
});
console.log(code.id); // → 'clx2k9f0a0000v8pq7h3m1abc' — an opaque cuid
valueis a whole percent (1–100) for percent types, smallest unit for fixed types.10means 10% forpercent/shipping_percent, and the discount is computed asfloor(base * value / 100). Anything outside 1–100 is rejected withINVALID_VALUE— there are no basis points here, so1000is an error, not 10%. Forfixed/shipping_fixedthe value is rupiah (IDR), cents (USD), etc. Mismatching this is the most common create error.
IDs are opaque cuids (
clx2k9f0a0000…), not prefixed ULIDs. Only workspace IDs carry a prefix (acc_,usr_). Never pattern-match adc_-style prefix — you'll reject valid IDs.
discountCodes.get
Signature. ripllo.discountCodes.get(id): Promise<DiscountCode>
Fetches one code by ID. Throws RiplloError with code: 'NOT_FOUND' if the code doesn't exist or belongs to another workspace.
const code = await ripllo.discountCodes.get('<discountCodeId>');
console.log(code.redemptionCount, code.active);
discountCodes.list
Signature. ripllo.discountCodes.list(params?): Promise<DiscountCodeListPage>
Cursor-paginated. Filters: active (boolean), limit (capped at 100), cursor. The page is { items, total, nextCursor, hasMore } — there is no data key and no cursor key on the response. See API → Discount codes for the HTTP-level shape.
const page = await ripllo.discountCodes.list({ active: true, limit: 50 });
for (const c of page.items) {
console.log(c.code, c.redemptionCount);
}
if (page.hasMore) {
const next = await ripllo.discountCodes.list({ active: true, limit: 50, cursor: page.nextCursor! });
}
discountCodes.update
Signature. ripllo.discountCodes.update(id, patch): Promise<DiscountCode>
PATCH semantics — only the fields you pass are touched. Use this to bump maxUsesTotal, extend expiresAt, or flip active. You cannot change code itself; archive and re-create if the string is wrong.
await ripllo.discountCodes.update('<discountCodeId>', {
expiresAt: '2026-12-31T23:59:59Z',
maxUsesTotal: 5000,
});
discountCodes.archive
Signature. ripllo.discountCodes.archive(id): Promise<{ id: string; active: boolean }>
Soft-deletes the code — it flips active to false and nothing else. There is no archivedAt column, so "archived" and "paused" are the same state, and update({ active: true }) fully un-archives a code. Subsequent validate calls return reason: 'INACTIVE' while it stays off.
discountCodes.validate
Signature. ripllo.discountCodes.validate(input): Promise<ValidateResult>
Read-only cart preview — your storefront backend calls this (signed; it is not a browser-callable endpoint) when the buyer types a code into the discount box. Returns the computed discount amount, the reason if rejected, and the resolved code row (matched case-insensitively).
The line-item array is items, and each line uses price, not unitPrice. Sending cartItems fails silently — the unknown key is stripped, items is optional, and a scope: 'products' or scope: 'tags' code then evaluates against an empty cart and comes back valid: false, reason: 'SCOPE_MISMATCH'. (Getting the inner field wrong is louder: a line without price 400s with VALIDATION.)
const result = await ripllo.discountCodes.validate({
accountId: 'acc_<merchantWorkspaceId>', // must match the authenticated principal
code: 'WELCOME10',
currency: 'IDR',
subtotal: 250_000,
shippingCost: 0,
customerId: 'cus_<buyer>',
items: [{ productId: 'p_001', quantity: 1, price: 250_000, tags: ['sale'] }],
});
if (!result.valid) {
// reasons are SCREAMING_SNAKE: 'NOT_FOUND' | 'INACTIVE' | 'EXPIRED' |
// 'NOT_YET_ACTIVE' | 'CURRENCY_MISMATCH' | 'MIN_PURCHASE' |
// 'GLOBAL_LIMIT' | 'PER_CUSTOMER_LIMIT' | 'SCOPE_MISMATCH'
return { error: result.reason };
}
console.log(result.discountAmount, result.discountShipping, result.code!.id);
ValidateResult is { valid, code?, reason?, discountAmount, discountShipping }. There is no appliedTo field.
discountCodes.redeem
Signature. ripllo.discountCodes.redeem(input): Promise<{ id: string; created: boolean }>
Commits a redemption. Call from your payment-success path (Plugipay webhook, Storlaunch order-paid hook). Idempotent on (accountId, checkoutSessionId) — retries return the same redemption with created: false. This is what increments redemptionCount and counts against maxUsesPerCustomer.
The field is discountCodeId (not codeId) and the amount is appliedAmount (not discountAmount). There is no currency field — currency is fixed by the code itself. Anything else 400s with VALIDATION.
await ripllo.discountCodes.redeem({
accountId: 'acc_<merchantWorkspaceId>', // must match the authenticated principal
discountCodeId: '<discountCodeId>',
customerId: 'cus_<buyer>',
checkoutSessionId: 'cs_<plugipaySession>',
appliedAmount: 25_000, // discount applied to the goods, minor units
appliedShipping: 0, // discount applied to shipping; defaults to 0
externalSource: 'storlaunch', // partner SDK callers should stamp this
externalRef: 'order_<storlaunchOrderId>',
});
The HTTP endpoint also accepts orderGrossIdr (subtotal + shipping before discount), which feeds the marketing-campaign revenue roll-up. It is not in the Node SDK's RedeemInput type yet, so pass it over raw HTTP if you need the roll-up to include this order.
discountCodes.applicable
Signature. ripllo.discountCodes.applicable(accountId, params?): Promise<{ items: PublicApplicableCode[] }>
Storefront teaser feed — returns every public: true code that would apply to a given cart shape. Use it to render a "you have available offers" hint above checkout.
const { items } = await ripllo.discountCodes.applicable('acc_<merchant>', {
currency: 'IDR',
subtotal: 250_000,
});
Types
type DiscountType = 'percent' | 'fixed' | 'shipping_percent' | 'shipping_fixed';
type DiscountScope = 'cart' | 'products' | 'tags';
interface DiscountCode {
id: string; // opaque cuid
accountId: string;
code: string;
description: string | null;
type: DiscountType;
value: number; // 1–100 for percent types; minor units for fixed
currency: string;
scope: DiscountScope;
productIds: string[];
tagFilter: string[];
minPurchaseAmount: number | null;
maxUsesTotal: number | null;
maxUsesPerCustomer: number | null;
startsAt: string | null;
expiresAt: string | null;
active: boolean;
public: boolean;
redemptionCount: number; // not `usesCount`
source: string | null; // 'manual' | 'referral_referrer' | 'referral_referee'
sourceRefId: string | null;
createdAt: string;
updatedAt: string;
}
interface DiscountCodeListPage {
items: DiscountCode[];
total: number;
nextCursor: string | null;
hasMore: boolean;
}
There is no archivedAt — archiving is just active: false.
Common patterns
Validate then redeem
const preview = await ripllo.discountCodes.validate({ /* ... */ });
if (!preview.valid) return rejectAtCheckout(preview.reason);
// ...buyer pays, payment webhook fires...
await ripllo.discountCodes.redeem({
accountId,
discountCodeId: preview.code!.id,
customerId,
checkoutSessionId,
appliedAmount: preview.discountAmount,
appliedShipping: preview.discountShipping,
});
Bulk-pause expired campaigns
async function* allCodes() {
let cursor: string | undefined;
while (true) {
const page = await ripllo.discountCodes.list({ limit: 100, cursor });
for (const c of page.items) yield c;
if (!page.hasMore) break;
cursor = page.nextCursor!;
}
}
for await (const c of allCodes()) {
if (c.active && c.expiresAt && Date.parse(c.expiresAt) < Date.now()) {
await ripllo.discountCodes.archive(c.id);
}
}
Errors
Error codes are SCREAMING_SNAKE, not snake_case — err.code === 'not_found' never matches.
| Code | Status | Cause |
|---|---|---|
VALIDATION |
400 | Bad enum, missing field, unknown field name, value out of range. |
INVALID_VALUE |
400 | Percent value outside 1–100, or a non-integer / non-positive value. |
CODE_EXISTS |
409 | A code with that string already exists in the workspace. |
NOT_FOUND |
404 | Code ID doesn't exist or lives elsewhere. |
NO_ACCOUNT |
403 | The principal carries no accountId. |
ACCOUNT_MISMATCH |
403 | validate / redeem body named a different workspace than the signed principal. |
INSUFFICIENT_SCOPE |
403 | The API key lacks the read scope (GETs) or the write scope (mutations). |
INACTIVE |
— | Not an error: validate returns it in ValidateResult.reason with HTTP 200. |
See API → Discount codes for the per-endpoint error tables and Authentication for auth-layer codes.
Next
- Referrals — issuing codes via the referral flow.
- Abandoned cart — pairing recovery emails with a code.
- API → Discount codes — HTTP-level reference.