Currency

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

value is a whole percent (1–100) for percent types, smallest unit for fixed types. 10 means 10% for percent / shipping_percent, and the discount is computed as floor(base * value / 100). Anything outside 1–100 is rejected with INVALID_VALUE — there are no basis points here, so 1000 is an error, not 10%. For fixed / shipping_fixed the 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 a dc_-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

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