Currency

Broadcasts

A broadcast is a blast send — one piece of content fanned out across one or more channels to a resolved audience. Email a launch announcement; SMS a flash-sale code; post to a Discord webhook and a Slack channel simultaneously. Broadcasts are the "what" and "to whom"; the actual delivery happens via per-message rows queued in MarketingMessage and dispatched by a worker.

Broadcasts are distinct from creator campaigns (/api/v1/campaigns), which are creator-marketplace briefs, and from the marketing-campaign hub (/api/v1/marketing-campaigns), which is the container a broadcast can be tied to for roll-up reporting. The broadcast resource lives at /api/v1/broadcasts; the legacy /api/v1/marketing-campaigns alias was retired when the hub took that URL. The hub's own endpoints are documented in The marketing-campaign hub at the bottom of this page.

The lifecycle is:

  1. Create a draft. Pick channels, write content per channel.
  2. Update until ready. Audience can be lists, contact IDs, or audience segments.
  3. Send-test to verify rendering on one recipient.
  4. Send to fan out for real. Status flips to sending.
  5. The worker delivers each MarketingMessage, moving those rows to sent/failed. The broadcast row itself stays in sending — nothing transitions it to sent yet (see the broadcast object), so monitor completion through statusCounts, not through status.

Endpoints

Method Path Purpose
GET /api/v1/broadcasts List broadcasts
POST /api/v1/broadcasts Create a broadcast
GET /api/v1/broadcasts/:id Retrieve a broadcast
PATCH /api/v1/broadcasts/:id Update a broadcast
POST /api/v1/broadcasts/:id/send Send the broadcast
POST /api/v1/broadcasts/:id/send-test Send a test message
POST /api/v1/broadcasts/:id/cancel Cancel the broadcast
POST /api/v1/broadcasts/templates/compile Server-side compile of a blocks doc to HTML+text.
GET /api/v1/broadcasts/templates List templates
POST /api/v1/broadcasts/templates Create a template.
PATCH /api/v1/broadcasts/templates/:tid Update a template.
DELETE /api/v1/broadcasts/templates/:tid Delete a template.

All endpoints require the merchant role.

There is no DELETE /api/v1/broadcasts/:id — broadcasts have no delete or archive route today. /cancel (which flips the row to paused) is the only way to take a broadcast out of flight.

Providers

A broadcast carries a list of providers (1–10) that determine which channels to dispatch through. The full list:

Category Providers
Email email_resend, email_sendgrid, email_mailgun, email_postmark, email_ses
SMS sms_twilio, sms_vonage
Messaging whatsapp_cloud, whatsapp_twilio, telegram_bot, line_business, discord_webhook, slack_webhook
Push push_onesignal, push_fcm
Social meta_business, linkedin, tiktok_business, twitter, youtube, pinterest, threads
Generic webhook_generic

A broadcast can only use providers for which the merchant has an active channel integration. Sending a broadcast with a provider that has no matching channel returns 400 NO_CHANNEL.

Create a broadcast

POST /api/v1/broadcasts

Request body

Field Type Required Notes
name string (1–120) yes Internal label.
description string (≤2000) | null no
providers enum[] (1–10) yes At least one provider. See Providers.
content object no Per-channel content. Keys are channel-content keys: email, sms, whatsapp, telegram, discord, slack, plus generic webhook etc. Email content typically { subject, html, text }; SMS { body }. Use templates to standardise.
audience object no { listIds?: string[], contactIds?: string[], segmentIds?: string[] }. Union semantics: a contact present in any source is included exactly once.
scheduledAt ISO 8601 | null no Future-schedule the send. When set on create, the broadcast starts in scheduled status; otherwise draft.
marketingCampaignId string | null no Tie the broadcast to a marketing-campaign hub row so it rolls up into that campaign's counts.broadcastsSent. A campaign id from another workspace returns 400 VALIDATION (marketingCampaignId not found in this account).

Response — 201 Created

{
  "data": {
    "id": "clx3f8k2p0000qw3f8h2k9d1e",
    "accountId": "acc_9f2c1b7a",
    "name": "May launch announcement",
    "description": null,
    "providers": ["email_resend", "discord_webhook"],
    "content": { "email": { "subject": "We're back", "html": "...", "text": "..." } },
    "audience": { "listIds": ["clx3f8k2p0002qw3f1a4b7c2d"], "segmentIds": ["clx3f8k2p0003qw3f6e5d4c3b"] },
    "scheduledAt": null,
    "status": "draft",
    "startedAt": null,
    "marketingCampaignId": null,
    "createdAt": "2026-05-13T10:42:00.000Z",
    "updatedAt": "2026-05-13T10:42:00.000Z"
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

List broadcasts

GET /api/v1/broadcasts

Returns every broadcast in the workspace, newest first, as a bare array in data (no wrapper key). Each row includes a _count.messages field for the total number of MarketingMessage rows fanned out so far.

Retrieve a broadcast

GET /api/v1/broadcasts/:id

Returns the broadcast plus a statusCounts map summarising delivery state across MarketingMessage rows:

{
  "data": {
    "id": "clx3f8k2p0000qw3f8h2k9d1e",
    "name": "May launch announcement",
    /* ... */
    "statusCounts": {
      "queued": 3,
      "sending": 8,
      "sent": 487,
      "failed": 2,
      "skipped": 0
    }
  },
  "error": null,
  "meta": { "requestId": "...", "timestamp": "..." }
}

Update a broadcast

PATCH /api/v1/broadcasts/:id

Partial — same field set as create. Edits are rejected once the broadcast has transitioned to sending or sent — the queue is already drained and partially modifying content would be confusing.

Status error.code When
409 BAD_STATE Broadcast is already sending or sent. Cancel first if you really need to re-edit.
400 VALIDATION marketingCampaignId points at a campaign in another workspace.

Setting marketingCampaignId to null detaches the broadcast from its hub campaign.

Send the broadcast

POST /api/v1/broadcasts/:id/send

Resolves the audience, builds per-(contact × provider) MarketingMessage rows, and transitions the broadcast to sending. The worker picks the messages up from there.

The audience resolution is:

  1. Union audience.contactIds, members of every audience.listIds, and contacts matched by every audience.segmentIds.
  2. For each resolved contact, for each provider in providers, build a row — provided the contact has a usable recipient identifier for that channel (e.g., contacts without email are skipped for email_* providers).
  3. Channels marked webhook_generic, discord_webhook, slack_webhook produce exactly one row per provider, with recipient: "broadcast" and a null contactId — webhook posts fan once per channel, not once per contact. (The audience still has to resolve to at least one contact for the row to be built.)

Errors

Status error.code When
400 NO_PROVIDER Broadcast has zero providers. Pick at least one.
400 NO_CHANNEL No active channel integration for any of the picked providers.
400 EMPTY_AUDIENCE Resolved audience came back empty.
400 NO_RECIPIENTS Audience non-empty, but none of the contacts had a usable recipient for any of the picked channels.
409 BAD_STATE Broadcast already sending or sent.

Response

{ "data": { "id": "clx3f8k2p0000qw3f8h2k9d1e", "queued": 487 }, "error": null, "meta": { ... } }

queued is the count of MarketingMessage rows inserted. Reach this number, and the worker has all the rows it needs to deliver everything.

const result = await ripllo.broadcasts.send('clx3f8k2p0000qw3f8h2k9d1e');
console.log(`Queued ${result.queued} messages`);

Send a test message

POST /api/v1/broadcasts/:id/send-test

Bypasses audience resolution. Queues exactly one MarketingMessage so the worker dispatches it through the same path a real send would — useful for verifying rendering before committing the full send.

Request body

Field Required Notes
provider yes Must be one of the broadcast's providers.
recipient yes The destination. For email_* an address; for sms_* a phone; for whatsapp_cloud and whatsapp_twilio a phone (Twilio routing auto-prefixes whatsapp:).
Status error.code When
400 BAD_PROVIDER Provider not in the broadcast's providers.
400 NO_CHANNEL No active channel for that provider.

The test message is written with campaignId: null, so it never shows up in the broadcast's statusCounts.

Cancel the broadcast

POST /api/v1/broadcasts/:id/cancel

Marks any queued messages as skipped with lastError = "broadcast canceled", and flips broadcast status to paused. Messages already in sending or sent are left as-is — cancellation is forward-only.

Templates

Templates are saved drafts of broadcast content. They live under the same resource (/api/v1/broadcasts/templates) because conceptually a template is a broadcast without an audience or schedule.

Templates can carry a blocks document — a structured email layout the dashboard's builder produces. When set, the server compiles blocks to HTML+text on save and stamps the result into content.email.{html,text}. Send-time rendering reads content.email.{html,text} directly, so the compile is byte-identical between dashboard preview and actual delivery.

The blocks doc

{
  "subject": "We're back",
  "preheader": "Restocked and reorganized",
  "accentColor": "#ff5722",
  "blocks": [
    { "kind": "header", "logoKey": "s3://.../logo.png", "brandName": "ExampleCo" },
    { "kind": "hero", "title": "We're back", "subtitle": "Restocked and reorganized" },
    { "kind": "text", "body": "Hi {firstName}, ..." },
    { "kind": "cta", "label": "Shop now", "url": "https://example.com" },
    { "kind": "footer", "text": "ExampleCo Inc.", "businessAddress": "Jakarta", "unsubscribeUrl": "..." }
  ]
}

Supported block kinds: header, hero, text, cta, image, divider, footer. Up to 40 blocks per document.

Merge tags in any text field are single-brace and drawn from a fixed set: {firstName}, {lastName}, {brandName}, {discountCode}, {cartUrl}, {productUrl}. Not yet substituted: the resolver ships, but no send path calls it today, so a merge tag is delivered as literal text. Treat tags as a forward-compatible authoring convention, not a working personalisation feature.

Compile preview

POST /api/v1/broadcasts/templates/compile

Same compiler the save path uses. Send a blocks doc plus optional assetUrls map (S3 key → presigned-GET URL) and get back { html, text }. Lets the builder's preview iframe show the exact bytes the recipient will see.

Template CRUD

Method Path Notes
GET /templates List, most-recently-updated first. Payload is { "templates": [...] }.
POST /templates Create. Field: { name, description?, providers?, content?, blocks? }.
PATCH /templates/:tid Partial update. Setting blocks re-runs the compiler.
DELETE /templates/:tid Hard delete.
Status error.code When
409 NAME_EXISTS Template name collision.

The broadcast object

Field Type Nullable Notes
id string no Bare cuid (e.g. clx3f8k2p0000qw3f8h2k9d1e) — no prefix.
accountId string no
name, description string description yes
providers enum[] no At least one.
content object no Per-channel content.
audience object no listIds/contactIds/segmentIds.
scheduledAt ISO 8601 yes Future-send.
status enum no draft, scheduled, sending, sent, paused, archived. Only four are reachable through the API today: create gives draft or scheduled, /send gives sending, /cancel gives paused. Nothing sets sent or archivedstatus is not accepted by PATCH either.
startedAt ISO 8601 yes When /send ran.
completedAt ISO 8601 yes Reserved for the drain-complete transition; never written today.
marketingCampaignId string yes Parent campaign hub row, if any.
createdAt, updatedAt ISO 8601 no

The marketing message object (read-only)

The worker writes to MarketingMessage directly — you don't create these yourself. Listing them is on the roadmap; for now, the per-broadcast statusCounts summary covers most monitoring needs.

Field Type Notes
id string Bare cuid — no prefix.
accountId, campaignId, contactId string campaignId is the broadcast id. campaignId/contactId are null for test sends; contactId is also null on the single row webhook providers produce.
funnelId, funnelStepId string | null Set when the message came from a funnel send step rather than a broadcast.
channelIntegrationId string The channel actually used.
provider enum Which channel was selected.
recipient string Email/phone/handle/broadcast.
content object Snapshot at fan-out time.
status enum queued, sending, sent, delivered, bounced, failed, skipped, opened, clicked. delivered, bounced, opened and clicked are set from provider delivery/engagement webhooks where the channel supports them; the rest are set by the dispatcher.
providerMessageId string | null Provider-side id (Resend message id, WA wamid, Telegram message_id).
attempts int Dispatch attempts so far.
lastError string | null Provider error if failed/skipped.
scheduledAt, sentAt, deliveredAt, failedAt, createdAt, updatedAt ISO 8601

The marketing-campaign hub

/api/v1/marketing-campaigns is a different resource from the broadcast API above — it is the roll-up container a broadcast, discount code, referral program, creator brief, affiliate program, blog post, cart reminder or feed can be attached to via its nullable marketingCampaignId. It has no providers, no content, no audience and no send lifecycle.

Method Path Purpose
GET /api/v1/marketing-campaigns List campaigns, newest first. Optional ?status= takes a comma-separated list (?status=draft,live); unknown values are ignored. Payload is { "campaigns": [...] }.
POST /api/v1/marketing-campaigns Create. 201 with the bare campaign row.
GET /api/v1/marketing-campaigns/:id Campaign + _count of linked children.
GET /api/v1/marketing-campaigns/:id/full Campaign + every linked child collection + a performance roll-up.
PATCH /api/v1/marketing-campaigns/:id Partial update of the same fields as create. No status guard — a campaign can be edited in any status. 404 NOT_FOUND if it isn't yours.
DELETE /api/v1/marketing-campaigns/:id Soft-delete: sets status: "archived" and returns the updated row. Child FKs stay intact.
GET /api/v1/marketing-campaigns/_/selector Lightweight dropdown payload: up to 200 non-archived campaigns (draft/live/paused) as { id, name, status, goal }.

Also merchant-role only.

Request body (create / update)

Field Type Required Notes
name string (1–120) yes
description string (≤2000) | null no
goal enum no awareness (default), conversion, retention, launch, other.
status enum no draft (default), live, paused, completed, archived.
budgetIdr integer ≥ 0 | null no Planned spend, in rupiah.
startsAt, endsAt ISO 8601 | null no Drive the performance window.
notes string (≤5000) | null no

PATCH is the same schema with every field optional.

_count on list and detail

Both GET / rows and GET /:id carry a _count of the seven linked child collections: creatorBriefs, affiliatePrograms, discountCodes, cartReminders, referralPrograms, blogPosts, feeds. Broadcasts are not in _count — they come back as a full collection from /:id/full.

GET /:id/full

Returns { campaign, creatorBriefs, affiliatePrograms, discountCodes, cartReminders, referralPrograms, blogPosts, feeds, broadcasts, performance }. cartReminders is capped at the 100 most recent; the rest are unbounded.

performance is deliberately honest about what the schema can and cannot measure — null means "not measurable", 0 means "measured, and it's zero":

Field Notes
revenueIdr.fromAffiliators Σ AffiliateCommission.grossAmountIdr for pending/approved/paid rows on this campaign's programs.
revenueIdr.fromDiscounts Σ DiscountRedemption.orderGrossIdr, excluding redemptions already claimed by the affiliator leg (dedup: affiliator wins). null when no redemption carries a gross.
revenueIdr.fromCreators Always null — attributing revenue to a creator post needs conversion tracking Ripllo does not run.
revenueIdr.total fromAffiliators + (fromDiscounts ?? 0).
costIdr.creatorPayouts Σ Collaboration.netToCreatorIdr for paid collaborations (funds actually released).
costIdr.affiliatorCommissions Σ AffiliateCommission.commissionAmountIdr for the same non-void statuses.
costIdr.broadcastSends Always 0 — per-send pricing isn't modelled yet.
counts discountRedemptions, discountRedemptionsWithGross, discountRedemptionsExcludedForDedup, affiliatorAttributions, collabsDelivered, collabsInProgress, broadcastsSent, messagesSent.
roi (revenue.total - cost.total) / cost.total, 4dp. null when cost is zero.
windowStart / windowEnd From campaign.startsAt / endsAt. windowEnd falls back to the campaign's updatedAt when only startsAt is set; both are null when neither date is set.

counts.broadcastsSent counts linked broadcasts with status: "sent"; counts.messagesSent counts their MarketingMessage rows in sending, sent, delivered, bounced, failed, opened or clickedqueued and skipped never left the system, so they don't count. Both read 0 in practice: no code path moves a broadcast from sending to sent, so no broadcast qualifies. Read the broadcasts array from /:id/full instead of trusting these two counters.

revenueIdr.note and costIdr.note carry a plain-English explanation of any null or excluded row, built per request.

Events

Event type Fires on Status
ripllo.marketing_campaign.sent.v1 All queued messages drained — broadcast transitions to sent. Reserved — not currently emitted (and the transition itself isn't wired).
ripllo.marketing_campaign.canceled.v1 /cancel succeeds. Reserved.
ripllo.marketing_message.delivered.v1 Per-message success from the worker. Reserved.
ripllo.marketing_message.failed.v1 Per-message failure. Reserved.

Next

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