Extend

API sync: remote data into contact attributes

A script that pulls customer data from your shop, billing or CRM API and writes it onto Kommio contacts as custom attributes with an API key, in Node and Python with no dependencies: exact matching, writes only what changed, waits out rate limits, and runs on a schedule.

Last updated

What it is

An API key lets your own scripts and back office call Kommio's API within the permissions you give it. This sample is the most common script of that kind: it reads a JSON array of customer records from a remote API and writes two of their fields onto the matching Kommio contacts as custom attributes, so agents see each customer's plan and lifetime value beside every conversation and automation rules can use them. Two versions with the same behaviour, Node 22 and Python 3.9+, nothing beyond the standard library. On each run it:

  1. reads the source (SOURCE_URL, a JSON array of records);
  2. finds each record's contact: by external id (customer_id against the contact's external identity) when there is one, otherwise by email, exact and case-insensitive. GET /api/contacts?q= matches parts of names and identities, so the script keeps only exact matches;
  3. maps fields to attributes with MAPPING (lifetime_value → ltv, plan → plan) and writes only what changed with PUT /api/contacts/{id}, sending the contact's name, company, avatar and other attributes back as they are, because the PUT replaces them. A second run with the same data writes nothing;
  4. on 429 waits the Retry-After seconds and tries again; on a 5xx or a network error backs off 1, 2, 4, 8, 16 seconds; any other error fails that record with the API's message and the run carries on;
  5. prints one line per record and a summary, and exits 1 if any record failed. --dry-run reports what would change and writes nothing.

Records whose contact does not exist are reported as "no contact"; the script does not create contacts. For a schedule, --every 900 runs every 15 minutes in the foreground, or run it once from cron, a Kubernetes CronJob or your scheduler: */15 * * * * cd /opt/api-sync/node && node --experimental-strip-types sync.ts.

When to use it

Copy this whenever Kommio should know something your other systems know: a plan, a balance, an account manager, a renewal date. If the data should appear live from your system rather than be copied, an app with a panel reads it on demand instead; if Kommio should push changes to you, a webhook receiver.

Set up

  1. Define the attributes

    Settings → Custom attributes → add two contact attributes: key ltv, type number, and key plan, type text. Kommio checks values against the definition (an ltv defined as text fails the record with 422 ltv must be a string) and rejects attributes that are not defined. Since the PUT sends the contact's attributes back, a contact holding a value of a deleted definition fails with "unknown custom attribute" until that value is removed.

  2. Create an API key

    Settings → API keys → Create key with contacts:read and contacts:write only. The key starts with kmk_ and is shown once.

  3. Adapt the mapping

    Edit SourceRecord and MAPPING in sync.ts (or MAPPING in sync.py) to your API's fields.

VariableDefault
KOMMIO_URLhttp://localhost:8080your Kommio address (the API is under /api)
KOMMIO_API_KEY(required)the key from step 2
SOURCE_URLhttp://127.0.0.1:4700/customersthe remote JSON endpoint
PORT4700the mock source only

Flags: --dry-run, --every <seconds>.

Run

A tiny mock of the remote API serves testdata/customers.json, so you can try the script before pointing it at your own API.

# terminal 1: the mock source (skip with your own SOURCE_URL)
cd node && node --experimental-strip-types mock-remote.ts     # or: cd python && python3 mock_remote.py

# terminal 2: the sync
cd node   && KOMMIO_URL=https://support.example.com KOMMIO_API_KEY=<api key> node --experimental-strip-types sync.ts --dry-run
cd python && KOMMIO_URL=https://support.example.com KOMMIO_API_KEY=<api key> python3 sync.py --dry-run

Drop --dry-run to write.

The code

examples/api-sync/node/sync.ts
// Pull customer data from a remote JSON API into Kommio contact attributes.
// Node 22, no dependencies:
//
//   KOMMIO_URL=https://support.example.com KOMMIO_API_KEY=<api key> \
//   SOURCE_URL=http://127.0.0.1:4700/customers \
//   node --experimental-strip-types sync.ts [--dry-run] [--every 900]
//
// Each run reads the source, finds each record's contact (by external id,
// else by email), and writes the mapped custom attributes through the
// Kommio API. It only writes what changed, so running it again is safe;
// it waits out 429 answers (Retry-After) and retries server errors.
import { pathToFileURL } from 'node:url';

/** One record of the remote API. Adapt to yours. */
export type SourceRecord = { customer_id?: string; email?: string; plan?: string; lifetime_value?: number };

/**
 * Contact attribute key → value from a record. The keys must exist in
 * Settings → Custom attributes (contact): here `ltv` (number) and `plan` (text).
 */
export const MAPPING: Record<string, (r: SourceRecord) => unknown> = {
  ltv: (r) => r.lifetime_value,
  plan: (r) => r.plan,
};

export type Contact = { id: number; name: string; company?: string; avatar_url?: string; emails: string[]; external_ids: { kind: string; value: string }[]; custom_attributes: Record<string, unknown> | null };
export type Outcome = { key: string; result: 'updated' | 'unchanged' | 'missing' | 'would-update' | 'failed'; contact?: number; changes?: Record<string, unknown>; error?: string };

export type Options = {
  kommioUrl: string; // the Kommio address; the API is under /api
  apiKey: string; // an API key (starts with kmk_) with contacts:read and contacts:write
  sourceUrl: string;
  dryRun?: boolean;
  fetch?: typeof fetch;
  sleep?: (ms: number) => Promise<void>;
  log?: (line: string) => void;
  maxRetries?: number;
};

const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

/**
 * One API call with the key. 429: wait Retry-After (seconds) and try
 * again; 5xx or a network error: back off 1, 2, 4, 8, 16 seconds; other errors
 * are thrown with the API's message.
 */
export async function call<T>(o: Options, method: string, path: string, body?: unknown): Promise<T> {
  const f = o.fetch ?? fetch;
  const sleep = o.sleep ?? wait;
  const max = o.maxRetries ?? 5;
  for (let attempt = 0; ; attempt++) {
    let r: Response | null = null;
    try {
      r = await f(`${o.kommioUrl.replace(/\/$/, '')}/api${path}`, {
        method,
        headers: { Authorization: `Bearer ${o.apiKey}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
        body: body ? JSON.stringify(body) : undefined,
      });
    } catch (e) {
      if (attempt >= max) throw e;
    }
    if (r && r.ok) return (r.status === 204 ? undefined : await r.json()) as T;
    if (r && r.status !== 429 && r.status < 500) {
      const detail = await r.json().catch(() => ({})) as { detail?: string; title?: string };
      throw new Error(`${method} ${path}: ${r.status} ${detail.detail ?? detail.title ?? ''}`.trim());
    }
    if (attempt >= max) throw new Error(`${method} ${path}: gave up after ${attempt + 1} attempts (${r?.status ?? 'network error'})`);
    const retryAfter = Number(r?.headers.get('Retry-After'));
    const ms = r?.status === 429 && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt;
    o.log?.(`${method} ${path}: ${r?.status ?? 'network error'}, retrying in ${ms / 1000}s`);
    await sleep(ms);
  }
}

/** The contact a record belongs to: an exact external id, else an exact email. */
export async function findContact(o: Options, r: SourceRecord): Promise<Contact | null> {
  const search = async (q: string) => (await call<{ items: Contact[] }>(o, 'GET', `/contacts?q=${encodeURIComponent(q)}&limit=50`)).items;
  if (r.customer_id) {
    const hit = (await search(r.customer_id)).find((c) => c.external_ids?.some((x) => x.kind === 'external' && x.value === r.customer_id));
    if (hit) return hit;
  }
  if (r.email) {
    const email = r.email.trim().toLowerCase();
    // ?q= matches parts of names and identities; keep only the exact address.
    return (await search(email)).find((c) => c.emails?.some((e) => e.toLowerCase() === email)) ?? null;
  }
  return null;
}

/** The attributes that differ from what the contact has (undefined values are skipped). */
export function changes(current: Record<string, unknown> | null, record: SourceRecord): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [key, pick] of Object.entries(MAPPING)) {
    const v = pick(record);
    if (v !== undefined && JSON.stringify(current?.[key]) !== JSON.stringify(v)) out[key] = v;
  }
  return out;
}

/** One pass over the source. */
export async function syncOnce(o: Options): Promise<Outcome[]> {
  const f = o.fetch ?? fetch;
  const src = await f(o.sourceUrl, { headers: { Accept: 'application/json' } });
  if (!src.ok) throw new Error(`source answered ${src.status}`);
  const records = await src.json() as SourceRecord[];
  const out: Outcome[] = [];
  for (const r of records) {
    const key = r.customer_id ?? r.email ?? '?';
    try {
      const c = await findContact(o, r);
      if (!c) { out.push({ key, result: 'missing' }); continue; }
      const diff = changes(c.custom_attributes, r);
      if (Object.keys(diff).length === 0) { out.push({ key, result: 'unchanged', contact: c.id }); continue; }
      if (o.dryRun) { out.push({ key, result: 'would-update', contact: c.id, changes: diff }); continue; }
      // PUT replaces name, company, avatar and attributes: send them back as they are.
      await call(o, 'PUT', `/contacts/${c.id}`, { name: c.name, company: c.company, avatar_url: c.avatar_url, custom_attributes: { ...(c.custom_attributes ?? {}), ...diff } });
      out.push({ key, result: 'updated', contact: c.id, changes: diff });
    } catch (e) {
      out.push({ key, result: 'failed', error: (e as Error).message });
    }
  }
  return out;
}

function summary(rows: Outcome[]): string {
  const n = (k: Outcome['result']) => rows.filter((r) => r.result === k).length;
  return `updated ${n('updated')}, would update ${n('would-update')}, unchanged ${n('unchanged')}, no contact ${n('missing')}, failed ${n('failed')}`;
}

async function main() {
  const args = process.argv.slice(2);
  const every = args.includes('--every') ? Number(args[args.indexOf('--every') + 1]) : 0;
  const o: Options = {
    kommioUrl: process.env.KOMMIO_URL || 'http://localhost:8080',
    apiKey: process.env.KOMMIO_API_KEY || '',
    sourceUrl: process.env.SOURCE_URL || 'http://127.0.0.1:4700/customers',
    dryRun: args.includes('--dry-run'),
    log: (l) => console.log(l),
  };
  if (!o.apiKey) throw new Error('set KOMMIO_API_KEY (Settings → API keys, with contacts:read and contacts:write)');
  for (;;) {
    const rows = await syncOnce(o);
    for (const r of rows) console.log(`${r.result.padEnd(12)} ${r.key}${r.contact ? ` → contact ${r.contact}` : ''}${r.changes ? ` ${JSON.stringify(r.changes)}` : ''}${r.error ? ` ${r.error}` : ''}`);
    console.log(summary(rows));
    if (!every) { if (rows.some((r) => r.result === 'failed')) process.exitCode = 1; return; }
    await wait(every * 1000);
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((e) => { console.error(e.message); process.exit(1); });

Verify

  • The dry run lists would-update lines for contacts whose values differ.
  • After a real run, open one of those contacts in Kommio: ltv and plan show under its attributes. Run again: every line says unchanged.
  • Tests, without a running Kommio, against a fake API with the same routes, answers, search semantics and a 429:
cd node   && npm install && npm run typecheck && npm test   # npm install only fetches typescript and @types/node
cd python && python3 -m unittest test_sync.py

Which should I use?

An extension is one thing you add to Kommio from Settings › Marketplace; what it can do is one or more of these.

  • Sidebar panel Your own page beside the conversation, such as recent orders or a CRM record. Orders panel · reference
  • Message hook Check, rewrite or block a reply before it is sent, or an incoming message before it is stored. Orders panel · reference
  • Event subscription Signed events at your URL when conversations, messages or contacts change. Webhook receiver · reference
  • Agent bot Your bot answers new conversations first and hands off to a person. Echo bot · reference
  • Remote data lookup Fetch fields such as balance or VIP tier from your API into the contact and a sidebar panel; set up from the Marketplace, no code. Marketplace · reference
  • Automation action A rule or macro step that calls your system, such as sending a webhook. Send a webhook · reference
  • Rule or flow template Start an automation rule or a chatbot flow from a ready-made template in the Marketplace. Marketplace · reference

An API key is not an extension: your own scripts and back office call Kommio with a key you scope (this page).

Every claim on these pages is covered by a test in the repository, and the API reference is generated from the same contract every server serves at /api/openapi. Corrections to [email protected].