Ukigai

Access by invitation

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; every GET and the audience preview.
  • marketing:write — create/edit campaigns, content, contacts, and lists.
  • marketing:send — schedule, send, pause, and cancel. Kept separate from marketing:write on 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

  1. Create the campaign — just a name to start.
  2. Pick a sender — GET /senders to find a sender_id already 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).
  3. Write the content — PUT the message for your default locale.
  4. Build the audience — either add contacts and put them in a list, or target existing CRM data.
  5. Preview the audience — check who's actually eligible before you commit.
  6. 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" }'
FieldTypeRequired
namestringyes
sender_idstring (uuid)no — required before schedule/send
default_locale"en" | "es" | "pt"no (default en)
audience_filterobject, see belowno (default: all CRM leads)
subjectstringno — seeds the default-locale message
external_sourcestring (slug, e.g. sodtrack_research)no — both or neither with external_id
external_idstringno — 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>"
  }'
FieldTypeRequired
locale"en" | "es" | "pt"yes
subjectstringno (blank is allowed but schedule/send will reject it)
preview_textstringno
html_bodystringno (same rule as subject)
text_bodystringno

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 via list_ids and/or tags. 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" }'
FieldTypeRequired
emailstringyes
first_name, last_name, company_name, job_title, phone, countrystringno
tagsarray of stringno
preferred_locale"en" | "es" | "pt"no
list_idstring (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 schedule again with a new scheduled_at while the campaign is still scheduled.
  • Resume a paused campaign: call schedule or send again — both accept paused as 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).