API reference

Campaigns and flows

Create, schedule and cancel campaigns, read results 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 }
}
FieldMeaning
statusDRAFT, PENDING_APPROVAL (prepared by an AI assistant, waiting for a person), SCHEDULED, SENDING, SENT, CANCELLED, FAILED
editabletrue until the campaign starts sending (draft, pending approval or scheduled)
audienceWhile 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, reviewSkippedCountSet when the campaign leaves: emails queued, addresses skipped because suppressed, and contacts under review held back this time
statsCounters 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.

status
string
Only campaigns with this status.
page
integer
Page number, from 0.
200 OK
{
  "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.

name
string required
Internal name, 200 characters at most.
subject
string
500 characters at most. Variables allowed.
preheader
string
Inbox preview text after the subject. 500 characters at most.
fromName
string
Sender name. Defaults to the space's.
fromEmail
string
Sender address on a verified domain. Defaults to the space's.
replyTo
string
Reply-to address.
contentFormat
string
MARKDOWN (wrapped in the space design) or HTML (sent as given).
content
string
The body, 1,000,000 characters at most. Variables reference.
listId
string
The list that receives the campaign. Required to schedule.
segmentId
string
A segment that narrows the list.
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".

Marketing emails always carry an unsubscribe link. If your content has no {{ 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.

scheduledAt
string
ISO 8601 date-time. Leave out to send now.
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" }'
200 OK
{
  "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.

StatusCodeWhen
400bad_request, domain_not_verifiedMissing subject, content or list, or a date in the past
409conflictThe 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.

200 OK
{
  "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.

200 OK
[
  {
    "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"
  }
]
FieldValues
statusDRAFT, ACTIVE, PAUSED
triggerTypeLIST_SUBSCRIBED (with triggerListId) or EVENT (with triggerEventName)
entryRuleONCE: 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.

200 OK
{
  "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.