Campaigns and flows
Create a campaign as a draft, then schedule it. Campaigns explained.
Flows are built and activated in the dashboard; the API reads them. Flows explained.
The campaign object
{
"id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"name": "October newsletter",
"subject": "What's new in October",
"preheader": "Three features and a discount",
"fromName": "Acme",
"fromEmail": "news@acme.com",
"replyTo": null,
"contentFormat": "MARKDOWN",
"content": "Hi {{ contact.first_name | default: \"there\" }},\n\nHere is what we shipped this month…",
"listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f",
"segmentId": null,
"status": "SCHEDULED",
"editable": true,
"scheduledAt": "2026-10-20T08:00:00Z",
"createdVia": "API",
"createdByUserId": null,
"approvalRequestedAt": null,
"approvalRequestedBy": null,
"approvalNote": null,
"approvedAt": null,
"approvedByUserId": null,
"startedAt": null,
"completedAt": null,
"cancelledAt": null,
"failureReason": null,
"recipientCount": null,
"suppressedCount": null,
"reviewSkippedCount": null,
"createdAt": "2026-10-17T09:30:00Z",
"updatedAt": "2026-10-17T09:35:00Z",
"stats": {
"recipients": 0, "queued": 0, "sent": 0, "delivered": 0, "bounced": 0, "complained": 0,
"opened": 0, "clicked": 0, "unsubscribed": 0, "failed": 0, "suppressed": 0, "cancelled": 0,
"deliveryRate": 0.0, "bounceRate": 0.0, "complaintRate": 0.0,
"openRate": 0.0, "clickRate": 0.0, "unsubscribeRate": 0.0
},
"audience": { "total": 4210, "eligible": 4105, "underReview": 0, "reviewHeldBack": 0 }
}
| Field | Meaning |
|---|---|
status | DRAFT, PENDING_APPROVAL (prepared by an AI assistant, waiting for a person), SCHEDULED, SENDING, SENT, CANCELLED, FAILED |
editable | true until the campaign starts sending (draft, pending approval or scheduled) |
audience | While editable: subscribed members of the list (total), those it would reach now (eligible: active, matching the segment, not suppressed), how many of them are under review (underReview), and how many of those would be held back if it left now (reviewHeldBack) |
recipientCount, suppressedCount, reviewSkippedCount | Set when the campaign leaves: emails queued, addresses skipped because suppressed, and contacts under review held back this time |
stats | Counters and rates (from 0 to 1). Opens and clicks are unique and made by people, not privacy proxies or link scanners |
List campaigns
GET /v1/campaigns
Newest first, 25 per page, with their stats.
0.{
"campaigns": [
{
"id": "2c3d4e5f-…",
"name": "October newsletter",
"subject": "What's new in October",
"status": "SENT",
"listId": "5a8e2c1f-…",
"segmentId": null,
"scheduledAt": "2026-10-20T08:00:00Z",
"startedAt": "2026-10-20T08:00:03Z",
"completedAt": "2026-10-20T08:21:40Z",
"createdVia": "API",
"createdAt": "2026-10-17T09:30:00Z",
"updatedAt": "2026-10-20T08:21:40Z",
"stats": { "recipients": 4105, "delivered": 4079, "opened": 1876, "clicked": 402, "…": "…" }
}
],
"page": 0,
"size": 25,
"total": 18,
"totalPages": 1
}
Create a campaign
POST /v1/campaigns
Creates a draft. Nothing is sent until you schedule it.
MARKDOWN (wrapped in the space design) or HTML (sent as given).curl https://api.kulamail.com/v1/campaigns \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "October newsletter",
"subject": "What'\''s new in October",
"contentFormat": "HTML",
"content": "<h1>October</h1><p>Hi {{ contact.first_name | default: \"there\" }}, …</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>",
"listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f"
}'
Returns 201 with the campaign object, status: "DRAFT".
{{ unsubscribe_url }}, Kula adds a small footer with one.Update a campaign
PATCH /v1/campaigns/{id}
Same fields as Create a campaign, all optional: fields left out stay as they are, a blank optional field is cleared. Works while the campaign is editable, otherwise answers 409.
Schedule a campaign
POST /v1/campaigns/{id}/schedule
Schedules the campaign at scheduledAt, or sends it right away when the body is empty. Calling it again moves the date.
curl -X POST https://api.kulamail.com/v1/campaigns/2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f/schedule \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "scheduledAt": "2026-10-20T08:00:00Z" }'
{
"campaign": { "id": "2c3d4e5f-…", "status": "SCHEDULED", "scheduledAt": "2026-10-20T08:00:00Z", "…": "…" },
"warnings": []
}
The campaign needs a subject, content, a list and a sender on a verified domain. When it leaves, the usual protections apply: warm-up of new domains, the review cap, and suppressions.
If the campaign can't leave at its time (for example, over your plan's monthly limit), it becomes FAILED and failureReason says why.
warnings flags a space whose marketing is paused: the campaign is scheduled, but its emails wait until sending resumes. Reputation.
| Status | Code | When |
|---|---|---|
| 400 | bad_request, domain_not_verified | Missing subject, content or list, or a date in the past |
| 409 | conflict | The campaign already left or was cancelled |
Cancel a campaign
POST /v1/campaigns/{id}/cancel
Stops a scheduled or sending campaign: waiting emails are cancelled, those already sent stay sent. Answers 409 if the campaign is not scheduled, pending approval or sending.
Get campaign stats
GET /v1/campaigns/{id}/stats
The full report: stats, most clicked links, results per mailbox provider, and unique opens per hour over the first 48 hours.
{
"stats": {
"recipients": 4105, "queued": 0, "sent": 4105, "delivered": 4079, "bounced": 26, "complained": 1,
"opened": 1876, "clicked": 402, "unsubscribed": 9, "failed": 0, "suppressed": 0, "cancelled": 0,
"deliveryRate": 0.9937, "bounceRate": 0.0063, "complaintRate": 0.0002,
"openRate": 0.4599, "clickRate": 0.0986, "unsubscribeRate": 0.0022
},
"topLinks": [
{ "url": "https://acme.com/blog/october", "clicks": 351, "uniqueClicks": 298 }
],
"providers": [
{ "provider": "GOOGLE", "stats": { "delivered": 2310, "opened": 1012, "…": "…" } },
{ "provider": "MICROSOFT", "stats": { "delivered": 980, "opened": 455, "…": "…" } }
],
"opensOverTime": [
{ "hour": "2026-10-20T08:00:00Z", "opens": 612 },
{ "hour": "2026-10-20T09:00:00Z", "opens": 301 }
]
}
Flows
Flows are read-only through the API: build and activate them in the dashboard (or let the AI assistant draft them). To make a contact enter a flow, record the event or subscribe them to the list that triggers it.
List flows
GET /v1/flows
Newest first.
[
{
"id": "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a",
"name": "Trial onboarding",
"description": null,
"status": "ACTIVE",
"triggerType": "EVENT",
"triggerListId": null,
"triggerEventName": "trial_started",
"entryRule": "ONCE",
"createdVia": "DASHBOARD",
"activationRequestedAt": null,
"activatedAt": "2026-10-02T10:00:00Z",
"activeRuns": 57,
"emailsSent": 1240,
"createdAt": "2026-10-01T15:20:00Z",
"updatedAt": "2026-10-02T10:00:00Z"
}
]
| Field | Values |
|---|---|
status | DRAFT, ACTIVE, PAUSED |
triggerType | LIST_SUBSCRIBED (with triggerListId) or EVENT (with triggerEventName) |
entryRule | ONCE: a contact goes through the flow once. EVERY_TIME: each trigger starts it again, unless a run is still in progress |
Get a flow
GET /v1/flows/{id}
The flow with its steps, each email step with its stats, and the run counts.
{
"id": "4d5e6f7a-…",
"name": "Trial onboarding",
"status": "ACTIVE",
"triggerType": "EVENT",
"triggerEventName": "trial_started",
"entryRule": "ONCE",
"…": "…",
"steps": [
{
"id": "a1b2…",
"position": 0,
"type": "EMAIL",
"waitMinutes": null,
"subject": "Welcome to your trial",
"preheader": null,
"fromName": null,
"fromEmail": null,
"replyTo": null,
"contentFormat": "MARKDOWN",
"content": "Hi {{ contact.first_name | default: \"there\" }}, …",
"condition": null,
"stats": { "sent": 620, "delivered": 617, "opened": 401, "…": "…" },
"createdAt": "2026-10-01T15:20:00Z",
"updatedAt": "2026-10-01T15:20:00Z"
},
{ "id": "c3d4…", "position": 1, "type": "WAIT", "waitMinutes": 4320, "…": "…" },
{
"id": "e5f6…",
"position": 2,
"type": "CONDITION",
"condition": {
"match": "all",
"conditions": [{ "type": "event", "name": "project_created", "operator": "not_performed" }]
},
"…": "…"
}
],
"runs": { "active": 57, "completed": 512, "exited": 51, "failed": 0 }
}
Step types: EMAIL, WAIT (waitMinutes) and CONDITION, whose condition uses the segment definition format: the contact continues if it matches, otherwise leaves the flow.