Currency

Concepts

This page covers the building blocks of Ripllo: what each object is, how they relate to each other, and the lifecycle they go through. Read this before you go deep on any specific feature — the rest of the docs assume you know what a discount code is and how it differs from a referral attribution.

The data model

Workspace
  ├── Discount codes
  │     └── Redemptions
  ├── Referral program (one per workspace)
  │     ├── Referral links
  │     └── Attributions
  ├── Abandoned-cart config
  │     └── Reminders
  ├── Merchant pixels
  ├── Merchant feed config (Google Merchant Center XML)
  ├── Blog posts
  ├── Marketing campaigns (roll-up hub — the objects above attach to one)
  │     └── Broadcasts (the actual email / WhatsApp / SMS sends)
  ├── API keys
  └── Webhook endpoints

Everything is scoped to a workspace. Cross-workspace queries don't exist — if you operate two storefronts, you'll have two workspaces and treat them as fully separate.

Workspace

A workspace is the top-level tenant boundary. Each workspace has its own discount codes, referral program, pixels, API keys, blog posts, and team members. Workspaces are isolated. Data never crosses between them.

There are two ways a workspace gets created:

  1. Direct sign-up — you sign up at ripllo.com and Ripllo creates a workspace anchored to your Huudis user ID (usr_<huudisUserId>).
  2. Partner-provisioned — Storlaunch (or another partner) creates a workspace on your behalf when you enable the Ripllo module. The anchor is the partner's account ID (acc_<storlaunchAccountId>), never usr_*.

The two namespaces never cross — this matters when you're seeding data via the partner SDK because using the wrong prefix orphans the rows.

Discount code

A discount code is a string a customer enters at checkout to reduce their total. Examples: WELCOME10, BLACKFRIDAY, FREESHIP.

A discount code has:

  • A unique code (case-insensitive within a workspace)
  • A type: percent (% off the cart), fixed (e.g. IDR 25,000 off the cart), shipping_percent or shipping_fixed — the two shipping_* variants come off the shipping cost instead of the cart subtotal, and report as discountShipping rather than discountAmount
  • A value: a whole percent 1100 for the percent types, or an amount in the smallest currency unit for the fixed types
  • A currency
  • A scope: cart (everything in the cart, the default), products (only specific product IDs), or tags (only items matching a tag filter). shipping_* codes ignore scope.
  • Optional minPurchaseAmount, maxUsesTotal, maxUsesPerCustomer, startsAt, expiresAt
  • An active flag — DELETE flips this to false; there is no separate archived state
  • A public flag — if true, the code shows up in the applicable-codes endpoint your storefront can hit for a teaser
  • A sourcemanual for merchant-authored codes, referral_referrer / referral_referee for rewards Ripllo issues itself. Only manual codes are ever listed publicly.

Discount redemption

A redemption is the record of one customer using one code on one order. Idempotent per workspace on (accountId, checkoutSessionId) — and again on (accountId, externalSource, externalRef) — so retried webhooks don't double-count.

A redemption stamps:

  • The discount code ID
  • The customer ID (null for guest checkout)
  • checkoutSessionId — the partner's opaque checkout reference, and the idempotency key
  • appliedAmount and appliedShipping — what actually came off the cart and off shipping
  • orderGrossIdr — the gross order value before discount, when the caller knows it (used by campaign revenue roll-up; null rows are excluded rather than estimated)
  • externalSource (e.g., 'storlaunch') and externalRef (the upstream order ID)

Referral program

A referral program is a single per-workspace configuration. It defines:

  • Whether the program is enabled
  • The reward type (percent or fixed) for both the referrer and the referee
  • referrerValue — reward for the existing customer who shares the link
  • refereeValue — reward for the new customer who clicks it
  • currency
  • minPurchaseAmount — the smallest order that triggers a reward
  • rewardExpiryDays — how long an issued reward stays valid
  • attributionWindowDays — how long after a click the referrer still gets credit
  • Optional maxRewardsPerReferrer

There's exactly one referral program per workspace, fetched and updated via GET/PUT /referrals/program.

A referral link is a personal share URL minted for a single existing customer. Two customers in the same program get different links. The link is the join key between "who shared it" and "who used it".

A link has:

  • A code (an 8-character URL-safe lowercase slug, unique per workspace)
  • A customerId (the referrer) — one link per customer, enforced at the DB level
  • Rolling counters: clicks, signups, rewards, and revenue (attributed gross, smallest currency unit)

Links don't expire. The time limit lives on the attribution instead (attributionWindowDays on the program sets its expiresAt).

Referral attribution

An attribution is the record of a referral chain: referrer X referred referee Y who placed order Z. Attributions move through a small lifecycle:

State Means
pending Referee signed up via the link but hasn't completed a qualifying purchase yet
rewarded Referee's first paid order cleared and reward codes have been minted for both sides (rewardedAt is stamped)
voided Order refunded; rewards clawed back
expired Attribution window ran out before a qualifying purchase

Those four are the whole enum — there is no qualified and no fulfilled intermediate state. A paid order takes an attribution straight from pending to rewarded.

The lifecycle is driven by the payment-success webhook (fulfillRewardOnPayment) and the refund webhook (voidAttributionOnRefund) from Plugipay or Storlaunch.

Abandoned-cart config

An abandoned-cart configuration is per-workspace settings for the recovery flow:

  • enabled — off by default; the merchant opts in
  • delayHours — how long after a cart goes idle before the reminder (default 4). Hours, not minutes: a delayMinutes field is silently ignored.
  • emailSubject — the subject line (default "You left something in your cart")
  • emailPreview — the inbox preview line (default "Come back to finish your order")
  • discountCodeId (optional) — a code attached to the reminder email

There is no sender field and no body template — the body is rendered by Ripllo from the cart snapshot.

Abandoned-cart reminder

A reminder is a single send event. It records:

  • The customer who abandoned, and the email it went to
  • The cart snapshot (line items at send time)
  • valueAtSend and currencyAtSend
  • The discount code attached, if any
  • externalSource and externalRef (the upstream cart ID)
  • recoveredAt and recoveredBySessionId — set later if the same customer completes checkout within the recovery window. It's a nullable timestamp plus the winning checkout session, not a boolean recovered flag.

Reminders honor the BuyerEmailPreference opt-out list — suppressed emails skip the send entirely.

Merchant pixels

Merchant pixels are tracking IDs the merchant configures so their storefront can fire analytics + ad-platform events. One row per workspace, holding:

  • Meta (Facebook) pixel ID + optional CAPI access token (server-side conversion API)
  • GA4 measurement ID
  • Google Ads conversion ID
  • TikTok pixel ID

The CAPI access token is never returned on the public storefront read endpoint (GET /pixels/public/:accountId) — only the public-facing IDs surface there.

Merchant feed config

A feed configuration holds settings for product feeds the storefront generates. Today: Google Merchant Center XML. The XML itself is generated on demand at GET /feeds/google/:accountId.xml and is shaped by exactly three fields:

  • enabled — defaults to true; the feed URL is the real opt-in, since ad networks won't poll until the merchant submits it
  • defaultGoogleProductCategory — the category applied to products that don't carry their own
  • includeUnpublished — defaults to false, so drafts stay out of the feed

There is no brand, country, language or currency setting on the config — those come from the product data at render time.

The product list itself is pulled from Storlaunch at feed-render time — Ripllo doesn't store the catalog.

Blog post

A blog post is the merchant's content-marketing record. Each post has:

  • A slug
  • A title and body (markdown)
  • An optional excerpt, coverImage, authorName, tags
  • A statusdraft or published, the only two values. There is no archived state; unpublishing means setting status back to draft.
  • SEO metaTitle and metaDescription
  • A publishedAt timestamp

Posts are served on the storefront at GET /blog/public/:accountId (list) and GET /blog/public/:accountId/:slug (single) — the slug lookup only exists on those public routes. The authenticated GET /blog/:id takes an ID.

Marketing campaign

A marketing campaign is not a send — it's the container everything else hangs off. It's the roll-up hub at /api/v1/marketing-campaigns: give a quarter's push a name, a goal and a budget, then attach the discount codes, referral program, loyalty program, blog posts, abandoned-cart reminders, feeds, creator briefs, affiliate programs and broadcasts that belong to it (each of those carries a nullable marketingCampaignId).

A campaign has:

  • A name and optional description
  • A goal: awareness (default), conversion, retention, launch, other
  • A status: draft (default) → livepausedcompletedarchived
  • An optional budgetIdr, startsAt / endsAt window, and free-form notes

It has no audience, no channel, no template and no sendAt — attaching a child is what makes a campaign do anything.

Broadcast

A broadcast is the one-shot send: the email/WhatsApp/SMS blast, at /api/v1/broadcasts. This is the object people usually mean by "campaign" elsewhere, and it is deliberately separate from the hub above.

A broadcast has:

  • providers — one or more channel slugs (email_resend, whatsapp_cloud, sms_twilio, discord_webhook, …). At least one is required.
  • content — per-channel payloads, e.g. { email: { subject, html, text } }
  • audience{ listIds, contactIds, segmentIds }; all three are expanded at send time
  • An optional scheduledAt, and an optional marketingCampaignId tying it to a hub campaign
  • A status lifecycle: draftscheduledsendingsent, plus paused and archived

Each recipient gets a MarketingMessage row per provider — except the webhook-style providers (discord_webhook, slack_webhook, webhook_generic), which post once per channel rather than once per contact.

API key

An API key authenticates server-to-server calls. Keys have:

  • An identifier — the generator emits AKIAFULK<16 hex chars> (the prefix is inherited from the shared Forjio key generator, not a copy/paste mistake on your side)
  • A secret shown once at creation, of the form fulksk_<base64url>
  • scopes — any of read, write, admin, defaulting to ["read","write"]. These are enforced: read covers GET/HEAD, write covers every mutation, and a key missing the one it needs gets 403 INSUFFICIENT_SCOPE. Partner keys additionally hold ripllo:platform:admin, which is a superset and is what unlocks X-Ripllo-On-Behalf-Of; it is seeded for partners, not self-serve.
  • A creation date
  • A last-used timestamp

Keys are per-workspace. They authenticate via HMAC signing of the request — see API → Authentication.

Identifiers

Resource IDs are opaque cuids with no type prefix — a discount code, a referral link, a blog post and a webhook endpoint all look like clx2k9f0a0000v8pq7h3m1abc. Two rules follow:

  • Never branch on an ID prefix. There is no disc_, rl_, post_ or mc_; code that pattern-matches one will reject every real ID.
  • Never treat IDs as time-ordered. cuids aren't ULIDs; sorting them lexicographically does not sort by creation time. Order by createdAt.

The prefixes that are real:

Prefix Type
usr_ Workspace anchored on a Huudis user (direct sign-up)
acc_ Workspace anchored on a partner account (e.g. Storlaunch)
evt_ Outbox event
crt_ Creator profile
aff_ Affiliator profile
AKIAFULK… API key access ID (the secret is fulksk_…)

Cursors in paginated list responses are just the last row's ID — pass them back verbatim, don't parse them.

Why this matters

The model isn't ornamental — it shapes how you build integrations:

  • Event types are named after the object, versioned: ripllo.<object>.<verb>.v1 (e.g. ripllo.loyalty.earned.v1). Most of the catalogue is reserved rather than live — the only events written today are the three loyalty ones, and nothing is delivered to a WebhookEndpoint yet. See Webhooks before you build a handler.
  • Idempotency is keyed on (accountId, externalSource, externalRef) for partner-provisioned writes, so you can safely retry a redemption, an attribution or a reminder call.
  • Audit log records actions on objects by ID, so you can trace who edited which discount.

Once you have this mental model, the rest of the docs are linear — each portal page, each API endpoint, each SDK method maps cleanly to one of these objects.

Next

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