Developers
Public Marketing API (v1)
Create and schedule newsletter campaigns — sender, content, and audience included — from an external system. Generate an API key from Marketing → API access inside your Ukigai organization, then follow the flow below.
All examples below use https://app.ukigai.com — replace it with your own
Ukigai URL.
Scope
v1 covers newsletter campaigns end to end: creating a campaign, writing its content, building an audience, and scheduling or sending it. This is a separate API from the CRM one — a Marketing API key only works on Marketing endpoints, and vice versa.
Authentication
Same two-step exchange as every Ukigai public API, using a key generated from Marketing → API access inside your organization:
curl -X POST https://app.ukigai.com/api/public/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"api_key": "uak_your_marketing_api_key_here"}'
{
"access_token": "uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"token_type": "Bearer",
"expires_in": 3600,
"request_id": "b3f2..."
}
Use the access token as Authorization: Bearer ... on every other call.
Your organization is always resolved from the token. A Marketing key
carries some combination of the marketing:read/marketing:write/
marketing:send scopes (see below) — it cannot call any CRM endpoint, and
a CRM key cannot call any Marketing endpoint.
Every response — success or error — includes a request_id (body and
X-Request-Id header); errors are { error, code, request_id }. See the
CRM API docs for the shared conventions (rate limits,
error format) — they apply here identically.
A key carries three scopes, granted independently when it's created (Marketing → API access):
marketing:read— always included; everyGETand the audience preview.marketing:write— create/edit campaigns, content, contacts, and lists.marketing:send— schedule, send, pause, and cancel. Kept separate frommarketing:writeon purpose: a key that only authors content can't also be used to actually email your audience.
Safe retries & durable identity
external_source / external_id (optional, on campaign create): the
same durable-identity pair the CRM API supports on
accounts/leads. Provide both or neither; they're unique per organization.
Creating again with a pair that already exists returns the existing
campaign (200, created: false) if every field you sent agrees with what's
stored, or 409 EXTERNAL_ID_CONFLICT if it doesn't.
Idempotency-Key header (optional, on create/schedule/send): a
client-generated key (a UUID is fine) unique to one logical attempt. A
retried request with the same key and the same body replays the current
result instead of repeating the action; the same key reused with a
different body is a client bug and returns 409 IDEMPOTENCY_KEY_CONFLICT.
It's optional everywhere it appears — omitting it on schedule is how you
reschedule (see below).
POST /api/public/v1/marketing/campaigns
Idempotency-Key: 8b297cf0-1234-4a3b-9abc-1234567890ab
Content-Type: application/json
{
"external_source": "sodtrack_research",
"external_id": "campaign:2026-10-newsletter",
"name": "October product update"
}
The order you'll actually use this in
- Create the campaign — just a name to start.
- Pick a sender —
GET /sendersto find asender_idalready configured in your organization (senders are set up by a human in the Marketing UI — SMTP credentials or domain verification aren't something this API creates). - Write the content —
PUTthe message for your default locale. - Build the audience — either add contacts and put them in a list, or target existing CRM data.
- Preview the audience — check who's actually eligible before you commit.
- Schedule or send.
Campaigns
POST /api/public/v1/marketing/campaigns
Requires scope marketing:write.
curl -X POST https://app.ukigai.com/api/public/v1/marketing/campaigns \
-H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "name": "October product update" }'
| Field | Type | Required |
|---|---|---|
name | string | yes |
sender_id | string (uuid) | no — required before schedule/send |
default_locale | "en" | "es" | "pt" | no (default en) |
audience_filter | object, see below | no (default: all CRM leads) |
subject | string | no — seeds the default-locale message |
external_source | string (slug, e.g. sodtrack_research) | no — both or neither with external_id |
external_id | string | no — both or neither with external_source |
Returns 201 (new) or 200 (idempotent replay / existing external-identity
match, created: false) with the campaign, status: "draft".
GET /api/public/v1/marketing/campaigns
Requires scope marketing:read. Optional status filter
(draft/scheduled/sending/sent/paused/failed); cursor pagination
(limit default 25, max 100), same shape as the CRM API's list endpoints.
GET /api/public/v1/marketing/campaigns/:id
Requires scope marketing:read. Includes live counters
(sent_count, delivered_count, bounced_count, opened_count,
clicked_count, unsubscribed_count, complained_count) — poll this
after sending to watch delivery progress.
Content
PUT /api/public/v1/marketing/campaigns/:id/messages
Requires scope marketing:write. Creates or replaces one language
variant. Only allowed while the campaign is draft, scheduled, or
paused.
curl -X PUT https://app.ukigai.com/api/public/v1/marketing/campaigns/CAMPAIGN_ID/messages \
-H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"locale": "en",
"subject": "October product update",
"html_body": "<p>Hi {{firstName}},</p><p>Here'\''s what shipped this month...</p>"
}'
| Field | Type | Required |
|---|---|---|
locale | "en" | "es" | "pt" | yes |
subject | string | no (blank is allowed but schedule/send will reject it) |
preview_text | string | no |
html_body | string | no (same rule as subject) |
text_body | string | no |
The default-locale message needs a non-empty subject and html_body
before the campaign can be scheduled or sent — every recipient without a
language preference of their own gets this variant.
GET /api/public/v1/marketing/campaigns/:id/messages
Requires scope marketing:read. Lists every language variant authored so
far.
Audience
A campaign's audience is a filter, resolved into actual recipients only when it starts sending — not a fixed list you upload at creation time.
{ "source": "crm_leads", "filters": {} }
{ "source": "crm_accounts", "filters": { "countries": ["CL", "MX"] } }
{ "source": "contact_lists", "filters": { "list_ids": ["LIST_ID"] } }
crm_leads/crm_accounts— targets your existing CRM data (see the CRM API docs to create/tag leads and accounts via API). Shared filters:countries,locales,created_after,owner_employee_id,include_archived.contact_lists— targets your Marketing contact book, scoped to specific lists vialist_idsand/ortags. Use this when your audience doesn't come from the CRM at all.
Whichever source you use, anyone on the organization's unsubscribe list is always excluded automatically — there's no way to override that.
POST /api/public/v1/marketing/campaigns/:id/audience/preview
Requires scope marketing:read. Resolves the audience without saving
anything or sending anything — a review artifact you can log before you
commit to schedule/send. Body is optional: omit it to preview the
campaign's saved audience_filter, or pass { "audience_filter": {...} }
to try a candidate filter first.
{
"audience_filter": { "source": "crm_leads", "filters": {} },
"eligible_count": 812,
"raw_count": 820,
"unsubscribed_count": 8,
"sample": [
{ "email": "jane@example.com", "name": "Jane Doe", "company_name": "Acme", "locale": "en" }
]
}
eligible_count is who actually gets emailed; raw_count is before
unsubscribe suppression (already deduplicated by email); the gap between
them is unsubscribed_count. sample is capped at 10 rows. This is the
same resolution the send-time cron uses the first time it picks up the
campaign — but it's re-run live, not pinned, so the real send can differ if
your CRM data changes in between.
POST /api/public/v1/marketing/contacts
Requires scope marketing:write. Upserts a contact by email (the natural
key — no external id or idempotency key needed; calling this twice with
the same email just updates the same contact). Optionally pass list_id
to add it to a list in the same call.
curl -X POST https://app.ukigai.com/api/public/v1/marketing/contacts \
-H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "email": "jane@example.com", "first_name": "Jane", "list_id": "LIST_ID" }'
| Field | Type | Required |
|---|---|---|
email | string | yes |
first_name, last_name, company_name, job_title, phone, country | string | no |
tags | array of string | no |
preferred_locale | "en" | "es" | "pt" | no |
list_id | string (uuid) | no |
Returns 201 (new contact) or 200 (existing contact updated),
created: true|false. An address already on the unsubscribe list stays
unsubscribed — adding it again never silently re-consents it.
GET /api/public/v1/marketing/contact-lists
Requires scope marketing:read. Lists your contact lists with member
counts.
POST /api/public/v1/marketing/contact-lists
Requires scope marketing:write. Body: { "name": "...", "description": "..." }
(description optional). 409 if the name is already used.
Senders
GET /api/public/v1/marketing/senders
Requires scope marketing:read. Read-only — lists the org's configured
senders so you can pick a sender_id. Creating a sender means SMTP
credentials or Resend domain verification, so that step stays in the
Marketing UI (Marketing → Senders).
{
"data": [
{
"id": "...",
"name": "Newsletter",
"provider": "resend_shared",
"from_email": "news@yourcompany.com",
"from_name": "Your Company",
"is_default": true,
"is_active": true
}
]
}
Scheduling & sending
Both of these are real triggers, not a preview: once queued, the
platform's existing per-minute cron resolves the audience and starts
emailing them. Both require a sender_id and a non-empty default-locale
message.
POST /api/public/v1/marketing/campaigns/:id/schedule
Requires scope marketing:send. Body: { "scheduled_at": "2026-10-01T13:00:00Z" }.
Returns the updated campaign.
POST /api/public/v1/marketing/campaigns/:id/send
Requires scope marketing:send. No body — sends as soon as the cron
next runs (within a minute). Returns the updated campaign.
Pausing, cancelling & rescheduling
- Reschedule: there's no separate endpoint — call
scheduleagain with a newscheduled_atwhile the campaign is stillscheduled. - Resume a paused campaign: call
scheduleorsendagain — both acceptpausedas a starting state, and only the recipients that haven't been sent yet go out.
POST /api/public/v1/marketing/campaigns/:id/pause
Requires scope marketing:send. Soft-stops a scheduled or sending
campaign — recipients already sent stay sent, the rest are picked up again
on resume. Idempotent: pausing an already-paused campaign just returns its
current state.
POST /api/public/v1/marketing/campaigns/:id/cancel
Requires scope marketing:send. Undoes a future schedule: valid from
scheduled or paused, both go back to draft with scheduled_at
cleared. Rejected with 400 CAMPAIGN_NOT_CANCELLABLE if the campaign is
sending (pause it instead — a send is actively in flight) or already
sent/failed (nothing to undo). Idempotent: cancelling an already-draft
campaign just returns its current state.
Errors
Same format as the CRM API: { error, code, request_id }. Common codes:
MISSING_NAME, INVALID_AUDIENCE_FILTER, INVALID_LOCALE,
CAMPAIGN_NOT_EDITABLE (editing content on a campaign that isn't
draft/scheduled/paused), CAMPAIGN_NOT_FOUND,
CAMPAIGN_SCHEDULE_FAILED/CAMPAIGN_SEND_FAILED (missing sender or
message — the error text says which), CAMPAIGN_NOT_PAUSABLE,
CAMPAIGN_NOT_CANCELLABLE, EXTERNAL_ID_CONFLICT (409),
IDEMPOTENCY_KEY_CONFLICT (409), INVALID_EXTERNAL_IDENTITY,
CONTACT_LIST_DUPLICATE_NAME (409), INSUFFICIENT_SCOPE (403 — e.g. a
marketing:write-only key calling schedule/send/pause/cancel),
MODULE_DISABLED (Marketing isn't enabled for your organization),
RATE_LIMITED (429, with a Retry-After header).