Campaigns
A Marketing Campaign in ripllo is the central hub that groups every marketing surface you run for a single push: a launch, a Black Friday weekend, a quiet-quarter retention drive, a creator-led product seed. The portal route is Dashboard → Campaigns.
The campaign object itself is small — name, goal, status, optional budget, optional start/end dates. The value is in what links into it: a campaign rolls up child marketing entities so you can see the whole effort in one place rather than seven different tabs.
Rule of thumb. If two or more tools are pulling in the same direction (a discount + an affiliator program + a creator brief, all promoting the same launch), tie them to one campaign. Otherwise leave the campaign link blank — every child entity works fine standalone.
What links into a campaign
Eight optional child entities can carry a marketingCampaignId:
| Child entity | What it is | Lives at |
|---|---|---|
| CreatorBrief | A creator-facing recruitment post (deliverables + budget). Spawns Collaborations once creators accept. | /dashboard/creators/briefs |
| AffiliateProgram | A commission-only program affiliators self-serve into. Mints AffiliateLink (discount code + referral link) on activation. | /dashboard/programs |
| DiscountCode | A coupon string customers enter at checkout. | /dashboard/discounts |
| Broadcast | A single email / WhatsApp / Telegram / SMS send to a contact list. Authored on the Compose page. | /dashboard/compose |
| AbandonedCartReminder | Per-send row of an abandoned-cart recovery email. Reminders inherit the campaign tag from the linked discount code at send time. | /dashboard/abandoned-cart |
| ReferralProgram | The merchant-wide referral program (dual-sided rewards). One per workspace. | /dashboard/referrals |
| BlogPost | A piece of content marketing published on the merchant blog. | /dashboard/blog |
| MerchantFeedConfig | The product-feed wiring (Meta Catalog / Google Merchant / etc.). | /dashboard/feeds |
Every link is optional and nullable. You can create child entities standalone, link them to a campaign later, or unlink them — the data stays put either way.
Campaign vs CreatorBrief vs Broadcast
Three names sit close together in the dashboard; the difference is real:
- A Campaign is the top-level hub — the umbrella under which a marketing push lives. It does not, itself, send anything or pay anyone. It exists so the eight child tools can roll up to a shared scoreboard.
- A CreatorBrief is a creator-facing recruitment post: "we need 5 IG reels for this launch, here's the budget, here's the script direction." Creators see it on the discover surface and apply. Briefs spawn Collaborations when applications are accepted.
- A Broadcast is a single email / WhatsApp / Telegram / SMS send to a contact list, authored in Compose. (Note: the table is named
MarketingCampaignon disk — legacy from before the central hub existed. The Prisma symbol and the dashboard label are bothBroadcast.)
A single Campaign can have many CreatorBriefs and many Broadcasts hanging off it.
Lifecycle
A campaign moves through five status values, settable from the detail page header:
draft ──► live ──► paused ──► live ─► … ─► completed ─► archived
draft— default on create. Visible only to your team. Tie child entities while you stage the launch.live— the campaign is running. Performance roll-up reflects only this window when start/end dates are set.paused— the campaign is on hold but not done. Children keep their links; you can resume at any time.completed— the campaign ran its course. Used for the dashboard "recent campaigns" pin.archived— soft-delete. Child entities keep theirmarketingCampaignId(so historical attribution survives) but the campaign drops out of selectors and most lists. Hard-delete is intentionally not exposed.
Status is a free-text transition — you can move from paused straight to archived, skip completed, or flip back from paused to live. The state machine is permissive on purpose; merchants run weird sprints.
Performance roll-up
The detail page Overview tab sums real numbers across linked entities. Each leg shows what the schema can honestly back; fields that would require cross-product data we don't have locally show "—" with a tooltip explaining why.
Sources
| Field | Source |
|---|---|
| Revenue → from affiliators | Sum of AffiliateCommission.grossAmountIdr for rows joined via enrollment → program → campaign, excluding voided commissions. |
| Revenue → from discounts | Sum of DiscountRedemption.orderGrossIdr for discount codes linked to the campaign, excluding any redemption also attributed to one of the campaign's affiliators (see dedup below). Rows where orderGrossIdr is null are excluded from the sum and surfaced separately in counts.discountRedemptionsWithGross. Returns null when no redemption on the campaign carries a gross. |
| Revenue → from creators | Not measured locally. Revenue attribution from creator content requires conversion tracking we don't yet run (a creator's IG post drives traffic that may convert via any leg). The cost side (creator payouts) IS measured; the revenue side is honest about its absence. |
| Cost → creator payouts | Sum of Collaboration.netToCreatorIdr for collaborations with status = paid (i.e. funds actually released, not just delivered or approved). |
| Cost → affiliator commissions | Sum of AffiliateCommission.commissionAmountIdr for rows in {pending, approved, paid} (voids excluded). |
| Cost → broadcast sends | Not modelled yet. Per-send pricing isn't on the schema. Reports as 0 with a tooltip. |
| Counts → discount redemptions | Exact count of DiscountRedemption rows for discount codes linked to the campaign. |
| Counts → discount redemptions with gross | Subset: redemptions whose orderGrossIdr is populated AND not dedup-excluded. Drives the revenueIdr.fromDiscounts sum. |
| Counts → discount redemptions excluded for dedup | Subset: redemptions whose orderGrossIdr was excluded because an affiliator commission also references the same redemption. |
| Counts → affiliator attributions | Exact count of AffiliateCommission rows (non-voided). |
| Counts → collabs delivered | Count of Collaboration rows with status in {delivered, approved, paid}. |
| Counts → collabs in progress | Count of Collaboration rows with status in {pending_funding, active}. |
| Counts → broadcasts sent | Exact count of Broadcast rows linked to the campaign with status = sent. Drafts, scheduled, paused, and archived broadcasts are excluded. |
| Counts → messages sent | Count of MarketingMessage rows for those sent broadcasts whose status is in {sending, sent, delivered, bounced, failed, opened, clicked} — i.e. messages that actually left the system. Queued + skipped rows don't count. |
| Window | campaign.startsAt and campaign.endsAt when set; otherwise null. We deliberately do NOT scan child rows to derive a window — a campaign with no children would otherwise return today's date as both bounds, which is misleading. |
| ROI | (revenue − cost) / cost, returned as a decimal ratio. null when cost is zero (avoids div-by-zero and "infinite ROI" headlines on empty campaigns). |
Discount × affiliator dedup
A single redemption can be attributed to both legs at once: when a customer uses an affiliator's auto-minted discount code, the redemption fires an AffiliateCommission row AND a DiscountRedemption row. Summing the gross on both legs would double-count the revenue.
The rule: the affiliator leg wins. It's the more specific attribution — the affiliator brought this customer; the discount is a stack-on incentive. The roll-up excludes any redemption from revenueIdr.fromDiscounts whose id appears as AffiliateCommission.sourceId (with sourceType = 'redemption') for one of the campaign's affiliate programs.
The exclusion count surfaces on counts.discountRedemptionsExcludedForDedup and a sibling note explains the math when the count is non-zero.
Honest UX
Creator revenue is the one leg still measured as null — it would require conversion tracking we don't run. Discount revenue is measured as soon as the redemption carries orderGrossIdr (storlaunch's payment-success path passes it through). Older redemptions and any caller that doesn't supply the gross still land as null, and the overview surfaces a "Tracking partial" callout in that case so the missing data is visible rather than papered over.
What the API returns
{
"performance": {
"revenueIdr": {
"total": 700000,
"fromDiscounts": 200000,
"fromAffiliators": 500000,
"fromCreators": null,
"note": "fromDiscounts excludes 1 redemption(s) also attributed to affiliators (dedup: affiliator leg wins). fromCreators: revenue attribution from creator content requires conversion tracking that is not yet wired."
},
"costIdr": {
"total": 880000,
"creatorPayouts": 850000,
"affiliatorCommissions": 30000,
"broadcastSends": 0,
"note": "broadcastSends: per-send pricing is not modelled in the schema yet …"
},
"counts": {
"discountRedemptions": 2,
"discountRedemptionsWithGross": 1,
"discountRedemptionsExcludedForDedup": 1,
"affiliatorAttributions": 2,
"collabsDelivered": 2,
"collabsInProgress": 1,
"broadcastsSent": 1,
"messagesSent": 247
},
"roi": -0.2045,
"windowStart": "2026-05-01T00:00:00.000Z",
"windowEnd": "2026-05-31T23:59:59.000Z"
}
}
Tying a child to a campaign
From the campaign detail page, each tab has an Add <child> button that deep-links into the relevant creation form with ?campaign=<id> pre-filled.
From the child tool's own page (e.g. /dashboard/discounts/new), every create form carries a Campaign dropdown sourced from GET /marketing-campaigns/_/selector — a lightweight list of non-archived campaigns for this workspace.
You can change the link later via PATCH on the child entity. Soft-deleting (archiving) a campaign sets its child links' relations to "orphaned" via Prisma's onDelete: SetNull — the child entity stays usable.
What you can't do (yet)
- Creator-content revenue attribution — a creator's IG post drives traffic that may convert via any leg; direct revenue attribution would need conversion tracking we don't run. Creator payouts (the cost side) IS measured.
- Backfill discount gross on historical rows — redemptions that landed before
DiscountRedemption.orderGrossIdrexisted (or via callers that don't pass the gross) stay null. New redemptions populate it automatically; a one-shot backfill from storlaunch would have to walk every CheckoutSession and is deferred. - Per-broadcast send pricing — Twilio WA / SMS would charge; email via Resend is bundled. The schema doesn't model per-send cost yet, so
costIdr.broadcastSendsis always 0. - Per-product budget burn — the
budgetIdrfield is a target; the dashboard doesn't yet show "spent vs budget." - Campaign templates — you create campaigns one at a time.
Next
- Discount codes — one of the seven children.
- Referrals — another.
- Marketing — pixels + abandoned-cart + broadcasts.
- API reference: marketing-campaigns — the full endpoint set.