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:
- Create a draft. Pick channels, write content per channel.
- Update until ready. Audience can be lists, contact IDs, or audience segments.
- Send-test to verify rendering on one recipient.
- Send to fan out for real. Status flips to
sending. - The worker delivers each
MarketingMessage, moving those rows tosent/failed. The broadcast row itself stays insending— nothing transitions it tosentyet (see the broadcast object), so monitor completion throughstatusCounts, not throughstatus.
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_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:
- Union
audience.contactIds, members of everyaudience.listIds, and contacts matched by everyaudience.segmentIds. - 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 foremail_*providers). - Channels marked
webhook_generic,discord_webhook,slack_webhookproduce exactly one row per provider, withrecipient: "broadcast"and a nullcontactId— 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 archived — status 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 clicked — queued 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
- Channels — the integrations campaigns dispatch through.
- Contact lists and Audience segments — the audience sources.