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:
- Direct sign-up — you sign up at ripllo.com and Ripllo creates a workspace anchored to your Huudis user ID (
usr_<huudisUserId>). - 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>), neverusr_*.
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_percentorshipping_fixed— the twoshipping_*variants come off the shipping cost instead of the cart subtotal, and report asdiscountShippingrather thandiscountAmount - A
value: a whole percent1–100for 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), ortags(only items matching a tag filter).shipping_*codes ignorescope. - Optional
minPurchaseAmount,maxUsesTotal,maxUsesPerCustomer,startsAt,expiresAt - An
activeflag —DELETEflips this tofalse; there is no separate archived state - A
publicflag — if true, the code shows up in the applicable-codes endpoint your storefront can hit for a teaser - A
source—manualfor merchant-authored codes,referral_referrer/referral_refereefor rewards Ripllo issues itself. Onlymanualcodes 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 keyappliedAmountandappliedShipping— what actually came off the cart and off shippingorderGrossIdr— 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') andexternalRef(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(percentorfixed) for both the referrer and the referee referrerValue— reward for the existing customer who shares the linkrefereeValue— reward for the new customer who clicks itcurrencyminPurchaseAmount— the smallest order that triggers a rewardrewardExpiryDays— how long an issued reward stays validattributionWindowDays— 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.
Referral link
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, andrevenue(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 indelayHours— how long after a cart goes idle before the reminder (default4). Hours, not minutes: adelayMinutesfield 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)
valueAtSendandcurrencyAtSend- The discount code attached, if any
externalSourceandexternalRef(the upstream cart ID)recoveredAtandrecoveredBySessionId— set later if the same customer completes checkout within the recovery window. It's a nullable timestamp plus the winning checkout session, not a booleanrecoveredflag.
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 itdefaultGoogleProductCategory— the category applied to products that don't carry their ownincludeUnpublished— 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
titleandbody(markdown) - An optional
excerpt,coverImage,authorName,tags - A
status—draftorpublished, the only two values. There is no archived state; unpublishing means settingstatusback todraft. - SEO
metaTitleandmetaDescription - A
publishedAttimestamp
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
nameand optionaldescription - A
goal:awareness(default),conversion,retention,launch,other - A
status:draft(default) →live→paused→completed→archived - An optional
budgetIdr,startsAt/endsAtwindow, and free-formnotes
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 optionalmarketingCampaignIdtying it to a hub campaign - A status lifecycle:
draft→scheduled→sending→sent, pluspausedandarchived
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 ofread,write,admin, defaulting to["read","write"]. These are enforced:readcoversGET/HEAD,writecovers every mutation, and a key missing the one it needs gets403 INSUFFICIENT_SCOPE. Partner keys additionally holdripllo:platform:admin, which is a superset and is what unlocksX-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_ormc_; 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 aWebhookEndpointyet. 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
- Portal tour — the dashboard, feature by feature.
- API reference — every endpoint.
- Authentication — how sign-in works.