Ukigai

Access by invitation

Developers

Public API (v1)

Read and write your organization's CRM data — accounts, leads, and opportunities — from an external system. Generate an API key from CRM → Settings → 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 the CRM module only: Accounts, Leads, Opportunities, owner lookup, research notes, email sequences, and the CRM's own reference data. Other modules aren't exposed through this API yet.

Authentication

Getting access is a two-step exchange:

  1. An organization admin generates an API key from CRM → Settings → API access. The raw key (uak_...) is shown exactly once — store it somewhere safe.
  2. Your integration exchanges that key for a short-lived access token (uat_...), valid for 1 hour, and sends the access token — not the API key — on every other request.

There is no refresh-token flow. When a token expires, call the token endpoint again with your API key to get a new one.

POST /api/public/v1/auth/token

curl -X POST https://app.ukigai.com/api/public/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"api_key": "uak_your_api_key_here"}'
{
  "access_token": "uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_type": "Bearer",
  "expires_in": 3600,
  "request_id": "b3f2..."
}

Every other endpoint requires this token:

Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Your organization is always resolved from this token — there is no organization id to pass in the URL or body.

Requests & responses

  • Every response — success or error — includes a request_id in the JSON body and as an X-Request-Id header. Send your own X-Request-Id header on a request and it's echoed back, so you can correlate a call across your own logs and ours if you need support.
  • A create endpoint returns the canonical resource. POST responses also include created: true (a new record was made) or created: false (an idempotent replay or an existing external-identity match was returned instead — see below).

Durable external identity & idempotent create

If you're importing or syncing records from another system, two things matter more than they might on a first pass: retrying a create that timed out shouldn't produce a duplicate, and re-running an import against the same external record shouldn't silently overwrite data someone in the CRM has since edited by hand. Accounts and leads support both.

external_source / external_id (optional, on account/lead create): external_source names your integration (e.g. sodtrack_research); external_id is any opaque id you assign (e.g. domain:example.com, person:98213). Provide both or neither. They're unique per organization — creating with an external_source/external_id pair that already exists:

  • matches the account/lead you have on record if every field you sent this time agrees with what's currently stored → returns that record, 200, created: false.
  • conflicts if any field you sent disagrees with what's currently stored (e.g. you send a different name for the same external_id) → 409 EXTERNAL_ID_CONFLICT. Nothing is overwritten. Fields you don't send are never part of this check — reassigning the owner in the CRM UI, for instance, never trips a conflict on a later re-import.

Idempotency-Key header (optional, on account/lead create): a client- generated key (a UUID is fine) unique to one logical create attempt. A retried request with the same key and the same body replays the original result instead of creating a second record; the same key reused with a different body is a client bug and returns 409 IDEMPOTENCY_KEY_CONFLICT. Only successful creates are remembered under a key — if a request fails, fix it and retry with the same key.

POST /api/public/v1/crm/accounts
Idempotency-Key: 8b297cf0-1234-4a3b-9abc-1234567890ab
Content-Type: application/json

{
  "external_source": "sodtrack_research",
  "external_id": "domain:example.com",
  "name": "Example"
}

Accounts

POST /api/public/v1/crm/accounts

Requires scope crm:write.

curl -X POST https://app.ukigai.com/api/public/v1/crm/accounts \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8b297cf0-1234-4a3b-9abc-1234567890ab" \
  -d '{
    "external_source": "sodtrack_research",
    "external_id": "domain:example.com",
    "name": "Acme Corp",
    "industry": "Manufacturing",
    "website": "https://acme.example.com",
    "phone": "+1 555 0100"
  }'
FieldTypeRequired
namestringyes
industrystringno
websitestringno
phonestringno
billing_addressobjectno
status"active" | "inactive"no (default active)
owner_employee_idstring (uuid)no
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 account. 409 on a conflict (see above).

GET /api/public/v1/crm/accounts/:id

Requires scope crm:read.

curl https://app.ukigai.com/api/public/v1/crm/accounts/ACCOUNT_ID \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

GET /api/public/v1/crm/accounts

Requires scope crm:read. Filtered, paginated search — use this to look up an account by external identity, or to reconcile/recover when you've lost your own mapping. At least one filter is required.

FilterMatches
idexact account id
external_source + external_idexact (must be given together)
domainthe account's website domain (e.g. example.com)
namecase-insensitive exact name match
cursor, limitpagination (limit default 25, max 100)
curl "https://app.ukigai.com/api/public/v1/crm/accounts?external_source=sodtrack_research&external_id=domain%3Aexample.com" \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "data": [ { "id": "...", "name": "Acme Corp", "...": "..." } ],
  "next_cursor": null,
  "request_id": "b3f2..."
}

Pass next_cursor back as cursor to fetch the following page; null means there are no more results.

Leads

The lead object

Every lead endpoint — create, get, list, update — returns the same shape:

{
  "id": "...",
  "account_id": "...",
  "first_name": "Jane",
  "last_name": "Doe",
  "email": "jane@acme.example.com",
  "alternate_emails": ["j.doe@acme.example.com"],
  "phone": "+56 9 1234 5678",
  "job_title": "VP Sales",
  "lead_role": "economic_buyer",
  "department": "Sales",
  "linkedin_url": "https://www.linkedin.com/in/jane-doe",
  "country": "CL",
  "preferred_locale": "es",
  "source": "referral",
  "source_notes": "Introduced by Acme's CTO at SaaSConf",
  "status": "working",
  "owner_employee_id": "...",
  "external_source": "sodtrack_research",
  "external_id": "person:98213",
  "created_at": "2026-01-01T00:00:00Z",
  "updated_at": "2026-01-02T00:00:00Z"
}

A field the lead doesn't have is null rather than absent, except alternate_emails, which is [].

The CRM also stores a national identification number on a lead. It is not part of this shape and is never returned by the API, so a crm:read token can't be used to pull identity documents out of the CRM.

FieldTypeNotes
emailstringThe primary address. Must be unique across the leads in your organization, counting alternates.
alternate_emailsarray of stringAliases for the same person. Lowercased, trimmed and de-duplicated on write; an entry equal to email is dropped. Inbound and outbound email matching uses these as well as email.
lead_rolestringThe person's role in the buying decision — see Reference data below. Free text is accepted for roles outside that set.
departmentstringFree text.
linkedin_urlstringStored as given. Search normalizes it — see the list filters below.
countrystringISO 3166-1 alpha-2, uppercased (CL, US). Anything else is stored as null.
preferred_localeen | es | ptThe language marketing campaigns write to this person in. null means no preference, so the campaign default is used. Anything else is stored as null.
sourcestringHow the lead reached you — see Reference data below. An unknown value is a 400.
source_notesstringFree text for referral or campaign provenance ("who referred them", "which booth").
statusnew | working | qualified | disqualifiedAn unknown value is a 400.

POST /api/public/v1/crm/leads

Requires scope crm:write. A lead always belongs to an existing account. Supports the same external_source/external_id/Idempotency-Key behavior as accounts, compared against account_id, first_name, last_name, email, phone, job_title, source, status.

curl -X POST https://app.ukigai.com/api/public/v1/crm/leads \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": "ACCOUNT_ID",
    "external_source": "sodtrack_research",
    "external_id": "person:98213",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@acme.example.com",
    "alternate_emails": ["j.doe@acme.example.com"],
    "job_title": "VP Sales",
    "lead_role": "economic_buyer",
    "linkedin_url": "https://www.linkedin.com/in/jane-doe",
    "country": "CL",
    "preferred_locale": "es",
    "source": "referral",
    "source_notes": "Introduced by their CTO at SaaSConf"
  }'
FieldTypeRequired
account_idstring (uuid)yes
first_namestringyes
last_namestringyes
emailstringno
alternate_emailsarray of stringno
phonestringno
job_titlestringno
lead_rolestringno
departmentstringno
linkedin_urlstringno
countrystring (ISO alpha-2)no
preferred_locale"en" | "es" | "pt"no
sourcestringno
source_notesstringno
status"new" | "working" | "qualified" | "disqualified"no (default new)
owner_employee_idstring (uuid)no
external_source / external_idsee Accountsno

Returns 201 (new) or 200 (replay/existing match, created: false) with the lead. 409 on a conflict, including LEAD_EMAIL_CONFLICT when any of the addresses you sent already belongs to another lead in your organization.

PATCH /api/public/v1/crm/leads/:id

Requires scope crm:write. A partial update: a field you omit is left alone, and an explicit null clears it. Moving a lead to a different account is an ordinary account_id update.

curl -X PATCH https://app.ukigai.com/api/public/v1/crm/leads/LEAD_ID \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "qualified",
    "lead_role": "champion",
    "alternate_emails": ["j.doe@acme.example.com", "jane.doe@acme.example.com"],
    "source_notes": null
  }'

Updatable: account_id, first_name, last_name, email, alternate_emails, phone, job_title, lead_role, department, linkedin_url, country, preferred_locale, source, source_notes, status, owner_employee_id, owner_business_partner_id.

Any other field in the body is a 400 FIELD_NOT_UPDATABLE listing what it rejected, rather than a silent no-op — including external_source and external_id, which are fixed at create time so the mapping you reconcile against can't move under you.

Note that alternate_emails replaces the list rather than appending to it; send the full set you want the lead to end up with.

Returns 200 with the lead. 404 LEAD_NOT_FOUND if it isn't yours, 409 LEAD_EMAIL_CONFLICT if an address now collides with another lead, 400 EMPTY_PATCH if the body has no updatable field at all.

GET /api/public/v1/crm/leads/:id

Requires scope crm:read.

GET /api/public/v1/crm/leads

Requires scope crm:read. Same shape as the accounts list. At least one filter is required.

FilterMatches
idexact lead id
account_idleads on that account
external_source + external_idexact (must be given together)
emailnormalized exact match against the lead's primary or alternate email
linkedin_urlthe same LinkedIn profile, matched on the /in/<slug> segment
first_namecase-insensitive exact match
last_namecase-insensitive exact match
cursor, limitpagination (limit default 25, max 100)

email, linkedin_url and the name filters are there for duplicate prevention: check before you create, rather than finding out from a 409.

linkedin_url compares profile slugs, so every way the URL might have been saved — with or without https://, www., a locale subdomain, a trailing slash, or tracking parameters — finds the same person, while jane-doe and jane-doe-2 stay distinct. It must be a profile URL (linkedin.com/in/<slug>); a company page or a bare name is a 400 INVALID_FILTER.

curl "https://app.ukigai.com/api/public/v1/crm/leads?linkedin_url=https%3A%2F%2Fwww.linkedin.com%2Fin%2Fjane-doe" \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Sequences

An AI-planned email cadence on a lead. Every step after the first goes through the same hold-window review the CRM UI already uses (visible and editable for a window before it auto-sends) — starting a sequence is the only fully autonomous part.

POST /api/public/v1/crm/leads/:leadId/sequences

Requires scope crm:write. Can take a while (AI research + generation, and possibly a real send) — allow up to 5 minutes.

If the lead already has a prior outbound email, the cadence threads onto it — nothing is sent by this call.

If it doesn't, this sends the opening email itself, through the lead's current owner's connected Gmail, before planning the cadence:

  • The owner must have Gmail connected with send permission, or this fails with a clear error (OPENING_SEND_FAILED) and nothing is created.
  • The lead's email is checked first with a syntax + MX-record lookup (not a bounce guarantee — see the Lead page's "Validate" action for the same check). A bad address returns 422 LEAD_EMAIL_INVALID and nothing is sent.
  • Pass subject + bodyText to send that verbatim, or omit both to have the AI draft the opening email from approachContext.
curl -X POST https://app.ukigai.com/api/public/v1/crm/leads/LEAD_ID/sequences \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "approachContext": "Reach out about their multi-country installation workforce and offer a scoping call."
  }'
FieldTypeRequired
approachContextstring (max 4000 chars)yes
objectivestring (max 500 chars)no
subjectstring (max 300 chars)no — both or neither with bodyText; only used when there's no prior email
bodyTextstring (max 8000 chars)no — both or neither with subject
holdWindowHoursintegerno (org default)
maxStepsintegerno (org default)

Returns 201 with the sequence (id, status, steps) plus sent_opening_email (true if this call sent it) and research_stale. 409 SEQUENCE_ALREADY_ACTIVE if the lead already has a draft/active/paused sequence — stop it in the CRM UI before starting another.

GET /api/public/v1/crm/leads/:leadId/sequences

Requires scope crm:read. Lists sequences on a lead, newest first — poll this after creating one to watch generation finish and steps get planned.

GET /api/public/v1/crm/leads/:leadId/sequences/:sequenceId

Requires scope crm:read.

Owners

GET /api/public/v1/crm/owners

Requires scope crm:read. Lists who's a valid owner_employee_id / owner_business_partner_id for account/lead create — active employees and business partners with CRM access, by default.

curl "https://app.ukigai.com/api/public/v1/crm/owners?status=active" \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "data": [
    {
      "kind": "employee",
      "id": "...",
      "display_name": "Jane Smith",
      "email": "jane@yourcompany.com",
      "active": true,
      "crm_eligible": true
    }
  ],
  "request_id": "b3f2..."
}

status is active (default) or all. An employee id maps to owner_employee_id; a partner id maps to owner_business_partner_id.

Research notes

POST /api/public/v1/crm/notes

Requires scope crm:write. Attaches a note with cited evidence to an account or lead — it shows up immediately in that record's normal CRM timeline. Source URLs are stored as given and are never fetched by Ukigai.

curl -X POST https://app.ukigai.com/api/public/v1/crm/notes \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "subject_type": "account",
    "subject_id": "ACCOUNT_ID",
    "body": "Operates a multi-country installation workforce...",
    "source_urls": ["https://example.com/operations"]
  }'
FieldTypeRequired
subject_type"account" | "lead"yes
subject_idstring (uuid)yes
bodystring (max 20,000 chars)yes
titlestring (max 200 chars)no (default "Research note")
source_urlsarray of http(s) URLs (max 20)no

Returns 201 with the created note.

Opportunities

POST /api/public/v1/crm/opportunities

Requires scope crm:write. An opportunity belongs to an account and must link to at least one lead on that same account. (Opportunities don't yet support external identity or idempotent create — that's accounts and leads only, for now.)

curl -X POST https://app.ukigai.com/api/public/v1/crm/opportunities \
  -H "Authorization: Bearer uat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": "ACCOUNT_ID",
    "lead_id": "LEAD_ID",
    "name": "Acme — annual contract",
    "amount": 24000,
    "currency": "USD",
    "stage": "prospecting"
  }'
FieldTypeRequired
account_idstring (uuid)yes
lead_ids (or lead_id)array of string / stringyes — at least one
namestringyes
amountnumberno
currencystring (ISO code)no (default USD)
stageprospecting | qualification | proposal | negotiation | closed_won | closed_lostno (default prospecting)
close_datestring (YYYY-MM-DD)no
probabilitynumber (0–100)no
owner_employee_idstring (uuid)no

Returns 201 with the created opportunity.

GET /api/public/v1/crm/opportunities/:id

Requires scope crm:read.

Reference data

GET /api/public/v1/crm/metadata

Requires scope crm:read. The value sets the CRM accepts, so you can validate before sending (or build a picker) without hard-coding a copy that goes stale when we add a source or a role.

{
  "lead_statuses": ["new", "working", "qualified", "disqualified"],
  "lead_sources": ["referral", "inbound", "outbound", "event", "partner", "marketing", "social_media", "other"],
  "lead_roles": ["decision_maker", "economic_buyer", "champion", "influencer", "gatekeeper", "technical_evaluator", "end_user", "procurement", "legal_compliance"],
  "preferred_locales": ["en", "es", "pt"],
  "countries": [{ "code": "CL", "name": "Chile" }],
  "request_id": "b3f2..."
}

lead_roles is the canonical set, but lead_role also accepts free text for a role outside it — that's what the CRM's own "Other (specify)" writes. Every other list is enforced on write: an unknown source or status is a 400, while an unknown preferred_locale or country is stored as null.

These lists are the same for every organization; the labels your users see in the CRM UI are localized from these ids.

Errors

Errors are a JSON object with error (human-readable), code (stable, machine-readable), and request_id:

{ "error": "Invalid or expired token", "code": "INVALID_TOKEN", "request_id": "b3f2..." }

Common status codes: 400 invalid input, 401 missing/invalid/expired token, 403 insufficient scope or the CRM module is disabled for your organization, 404 the resource doesn't exist (or doesn't belong to your organization), 409 EXTERNAL_ID_CONFLICT, IDEMPOTENCY_KEY_CONFLICT, LEAD_EMAIL_CONFLICT, or SEQUENCE_ALREADY_ACTIVE (see above), 422 LEAD_EMAIL_INVALID (starting a sequence with no prior email on a lead whose address fails the syntax/MX check), 429 rate limited.

400 codes worth handling specifically on lead writes:

CodeMeaning
FIELD_NOT_UPDATABLEThe PATCH body carried a field that endpoint won't write. details.fields lists them, details.updatable lists what it accepts.
EMPTY_PATCHThe PATCH body had no updatable field at all.
MISSING_FILTERA list request with no filter.
INVALID_FILTERA filter was given but couldn't be used — e.g. a linkedin_url that isn't a profile URL, or external_source without external_id.

Rate limits

  • POST /auth/token: 20 requests per minute, per IP.
  • All other endpoints: 120 requests per minute, per API key.

A 429 response includes a Retry-After header (seconds).