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:
- An organization admin generates an API key from CRM → Settings →
API access. The raw key (
uak_...) is shown exactly once — store it somewhere safe. - 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_idin the JSON body and as anX-Request-Idheader. Send your ownX-Request-Idheader 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.
POSTresponses also includecreated: true(a new record was made) orcreated: 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
namefor the sameexternal_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"
}'
| Field | Type | Required |
|---|---|---|
name | string | yes |
industry | string | no |
website | string | no |
phone | string | no |
billing_address | object | no |
status | "active" | "inactive" | no (default active) |
owner_employee_id | string (uuid) | no |
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 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.
| Filter | Matches |
|---|---|
id | exact account id |
external_source + external_id | exact (must be given together) |
domain | the account's website domain (e.g. example.com) |
name | case-insensitive exact name match |
cursor, limit | pagination (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.
| Field | Type | Notes |
|---|---|---|
email | string | The primary address. Must be unique across the leads in your organization, counting alternates. |
alternate_emails | array of string | Aliases 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_role | string | The person's role in the buying decision — see Reference data below. Free text is accepted for roles outside that set. |
department | string | Free text. |
linkedin_url | string | Stored as given. Search normalizes it — see the list filters below. |
country | string | ISO 3166-1 alpha-2, uppercased (CL, US). Anything else is stored as null. |
preferred_locale | en | es | pt | The language marketing campaigns write to this person in. null means no preference, so the campaign default is used. Anything else is stored as null. |
source | string | How the lead reached you — see Reference data below. An unknown value is a 400. |
source_notes | string | Free text for referral or campaign provenance ("who referred them", "which booth"). |
status | new | working | qualified | disqualified | An 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"
}'
| Field | Type | Required |
|---|---|---|
account_id | string (uuid) | yes |
first_name | string | yes |
last_name | string | yes |
email | string | no |
alternate_emails | array of string | no |
phone | string | no |
job_title | string | no |
lead_role | string | no |
department | string | no |
linkedin_url | string | no |
country | string (ISO alpha-2) | no |
preferred_locale | "en" | "es" | "pt" | no |
source | string | no |
source_notes | string | no |
status | "new" | "working" | "qualified" | "disqualified" | no (default new) |
owner_employee_id | string (uuid) | no |
external_source / external_id | see Accounts | no |
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.
| Filter | Matches |
|---|---|
id | exact lead id |
account_id | leads on that account |
external_source + external_id | exact (must be given together) |
email | normalized exact match against the lead's primary or alternate email |
linkedin_url | the same LinkedIn profile, matched on the /in/<slug> segment |
first_name | case-insensitive exact match |
last_name | case-insensitive exact match |
cursor, limit | pagination (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_INVALIDand nothing is sent. - Pass
subject+bodyTextto send that verbatim, or omit both to have the AI draft the opening email fromapproachContext.
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."
}'
| Field | Type | Required |
|---|---|---|
approachContext | string (max 4000 chars) | yes |
objective | string (max 500 chars) | no |
subject | string (max 300 chars) | no — both or neither with bodyText; only used when there's no prior email |
bodyText | string (max 8000 chars) | no — both or neither with subject |
holdWindowHours | integer | no (org default) |
maxSteps | integer | no (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"]
}'
| Field | Type | Required |
|---|---|---|
subject_type | "account" | "lead" | yes |
subject_id | string (uuid) | yes |
body | string (max 20,000 chars) | yes |
title | string (max 200 chars) | no (default "Research note") |
source_urls | array 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"
}'
| Field | Type | Required |
|---|---|---|
account_id | string (uuid) | yes |
lead_ids (or lead_id) | array of string / string | yes — at least one |
name | string | yes |
amount | number | no |
currency | string (ISO code) | no (default USD) |
stage | prospecting | qualification | proposal | negotiation | closed_won | closed_lost | no (default prospecting) |
close_date | string (YYYY-MM-DD) | no |
probability | number (0–100) | no |
owner_employee_id | string (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:
| Code | Meaning |
|---|---|
FIELD_NOT_UPDATABLE | The PATCH body carried a field that endpoint won't write. details.fields lists them, details.updatable lists what it accepts. |
EMPTY_PATCH | The PATCH body had no updatable field at all. |
MISSING_FILTER | A list request with no filter. |
INVALID_FILTER | A 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).