# Kustomer Developers: full documentation > Build on Kustomer: REST APIs, webhooks, workflows, apps, chat SDKs and AI tooling, documented for engineers and for the models they work with. # Build on Kustomer > What you can build on the Kustomer platform, how the pieces fit together, and where to start. Source: https://developer.kustomer.com/docs/get-started Kustomer is a customer service platform built around a single timeline per customer: every conversation, message, order and event lands on one record. The developer platform exposes that same data model through a REST API, real-time events, an apps platform and embeddable chat SDKs. This site covers all of it, for engineers and for the AI tools they work with. Every page has a Markdown version, the whole site is summarized in [`/llms.txt`](https://developer.kustomer.com/llms.txt), and every API operation has its own Markdown page in the [API reference](https://developer.kustomer.com/api). ## What you can build - [Sync data in and out](https://developer.kustomer.com/docs/guides/sync-customers-from-crm): Keep customers, companies and orders in step with your CRM, warehouse or commerce stack using the REST API and bulk endpoints. - [React to changes](https://developer.kustomer.com/docs/events): Subscribe to events such as `kustomer.conversation.update` and push them to your systems with outbound webhooks. - [Automate support](https://developer.kustomer.com/docs/workflows): Build event-driven workflows with conditions, expressions, REST calls and code steps that run inside Kustomer. - [Ship an app](https://developer.kustomer.com/docs/apps): Package custom objects, timeline views, workflows and settings into an installable app definition. - [Embed chat](https://developer.kustomer.com/docs/chat): Add Kustomer Chat to your website, iOS, Android or React Native app with the chat SDKs. - [Work with AI](https://developer.kustomer.com/docs/ai): Drive Kustomer from scripts and AI agents with the `kusty` CLI and MCP server, and feed these docs to your tools. ## How the platform fits together ```mermaid flowchart LR subgraph You["Your systems"] S[Backend services] W[Website and mobile apps] A[AI agents and scripts] end subgraph K["Kustomer"] API[REST API] EV[Event stream] WF[Workflows] AP[Installed apps] CH[Chat] end S -- "HTTPS + API key" --> API A -- "kusty / MCP" --> API W -- "Chat SDKs" --> CH API --> EV CH --> EV EV --> WF EV -- "Outbound webhooks" --> S WF -- "REST actions" --> S AP -. "Klasses, KViews, workflows" .-> K ``` - **REST API.** Everything an agent can see or do is a resource: customers, conversations, messages, custom objects, users, queues, knowledge base articles and settings. Base URL `https://.api.kustomerapp.com`. - **Events.** Every create and update emits an event named `kustomer..`. Workflows consume them inside Kustomer, and outbound webhooks deliver them to you. - **Apps.** An app definition is a JSON document that bundles Klasses (custom object types), KViews (UI views), workflows, settings and webhooks so you can install them into any org. - **Chat SDKs.** Native and web SDKs that put Kustomer Chat inside your product and identify signed-in customers. ## Start here #### Create an API key In Kustomer, go to **Settings › Security › API Keys** and add a key with the roles your integration needs. See [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles). #### Make your first call Follow the [quickstart](https://developer.kustomer.com/docs/get-started/quickstart) to read your org, create a customer and open a conversation in about five minutes. #### Learn the data model Read [Data model overview](https://developer.kustomer.com/docs/concepts/data-model) to see how customers, conversations, messages and custom objects relate. #### Pick a guide Jump to a [guide](https://developer.kustomer.com/docs/guides) for the integration you're building, or browse the [API reference](https://developer.kustomer.com/api). > **Using an AI assistant?:** Point it at [`/llms.txt`](https://developer.kustomer.com/llms.txt) for a map of the site or [`/llms-full.txt`](https://developer.kustomer.com/llms-full.txt) for every page in one file. Any docs URL also works with a `.md` suffix. The [Docs for AI](https://developer.kustomer.com/docs/ai/docs-for-ai) page explains each format. --- # Quickstart > Create an API key, verify it, create a customer, open a conversation and add a message, in about five minutes. Source: https://developer.kustomer.com/docs/get-started/quickstart This quickstart walks through the calls nearly every integration makes: check who you are, create a customer, open a conversation for them, and add a message. You need admin access to a Kustomer org. Use a sandbox org if you have one. ## 1. Create an API key #### Open API keys In Kustomer, go to **Settings › Security › API Keys** and select **Add API Key**. #### Name the key and pick roles Give it a name like `quickstart`. For this tutorial, add these roles: `org.permission.customer.create`, `org.permission.customer.read`, `org.permission.conversation.create` and `org.permission.message.create`. Roles can't be changed after creation, so pick what your integration needs. See [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles). #### Copy the token Set an expiry (or none) and, optionally, a CIDR IP restriction. Then create the key and copy the token. **Kustomer only shows it once.** Store the token and your org name in environment variables. Your org name is the subdomain you use to sign in: for `acme.kustomerapp.com`, it's `acme`. ```bash title="Shell" export KUSTOMER_API_KEY="eyJhbGciOi..." export KUSTOMER_ORG="acme" ``` > **Treat API keys like passwords:** A key acts as a machine user with the roles you gave it. Keep keys in a secrets manager, never in client-side code or source control. Keys can't be edited, so rotate them by creating a new key and deleting the old one. ## 2. Verify the key Ask the API which token you're using. This works with any valid key, whatever its roles. `GET /v1/auth/tokens/current` ([reference](https://developer.kustomer.com/api/access-management/get-current-auth-token)) ```bash title="cURL" curl "https://$KUSTOMER_ORG.api.kustomerapp.com/v1/auth/tokens/current" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` ```ts title="Node.js" const base = `https://${process.env.KUSTOMER_ORG}.api.kustomerapp.com` const headers = { Authorization: `Bearer ${process.env.KUSTOMER_API_KEY}`, 'Content-Type': 'application/json' } const res = await fetch(`${base}/v1/auth/tokens/current`, { headers }) console.log(res.status, await res.json()) ``` ```python title="Python" import os, requests base = f"https://{os.environ['KUSTOMER_ORG']}.api.kustomerapp.com" headers = {"Authorization": f"Bearer {os.environ['KUSTOMER_API_KEY']}"} res = requests.get(f"{base}/v1/auth/tokens/current", headers=headers) print(res.status_code, res.json()) ``` ```bash title="kusty" kusty api GET /v1/auth/tokens/current ``` A `200` response describes the token, including its roles and the machine user behind it: ```json title="200 OK" { "data": { "type": "auth_token", "id": "5822276811e88a1000787abe", "attributes": { "name": "quickstart", "lastTokenChars": "xyz123", "roles": ["org.permission.customer.create", "org.permission.customer.read", "..."] }, "relationships": { "org": { "data": { "type": "org", "id": "57f29863a1dbf61100e6aa92" } }, "user": { "data": { "type": "user", "id": "58190b991f2932100010d683" } } } } } ``` If you get `401`, check that the header reads `Bearer`, a space, then the token, and that you're using your org's host. See [Errors](https://developer.kustomer.com/docs/concepts/errors). ## 3. Create a customer Customers are the center of the data model. Give each one an `externalId` that matches the ID in your own system so you can find them again without storing Kustomer IDs. `POST /v1/customers` ([reference](https://developer.kustomer.com/api/core-resources/create-customer)) ```bash title="cURL" curl -X POST "https://$KUSTOMER_ORG.api.kustomerapp.com/v1/customers" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane Doe", "externalId": "crm-10293", "emails": [{ "type": "home", "email": "jane.doe@example.com" }], "tags": ["quickstart"] }' ``` ```ts title="Node.js" const customer = await fetch(`${base}/v1/customers`, { method: 'POST', headers, body: JSON.stringify({ name: 'Jane Doe', externalId: 'crm-10293', emails: [{ type: 'home', email: 'jane.doe@example.com' }], tags: ['quickstart'], }), }).then((r) => r.json()) const customerId = customer.data.id ``` ```python title="Python" customer = requests.post(f"{base}/v1/customers", headers=headers, json={ "name": "Jane Doe", "externalId": "crm-10293", "emails": [{"type": "home", "email": "jane.doe@example.com"}], "tags": ["quickstart"], }).json() customer_id = customer["data"]["id"] ``` ```bash title="kusty" kusty api POST /v1/customers --data '{ "name": "Jane Doe", "externalId": "crm-10293", "emails": [{ "type": "home", "email": "jane.doe@example.com" }], "tags": ["quickstart"] }' ``` Requests send plain JSON attributes. Responses come back wrapped in a `data` envelope with `type`, `id`, `attributes` and `relationships`. [Request and response format](https://developer.kustomer.com/docs/concepts/json-api-format) explains the envelope. ```json title="201 Created" { "data": { "type": "customer", "id": "5822a4a5aa5f92110005d69a", "attributes": { "name": "Jane Doe", "displayName": "Jane Doe", "externalId": "crm-10293", "emails": [{ "email": "jane.doe@example.com", "verified": false, "type": "home" }], "tags": ["quickstart"], "createdAt": "2026-10-21T14:02:11.925Z" }, "links": { "self": "/v1/customers/5822a4a5aa5f92110005d69a" } } } ``` ## 4. Open a conversation `POST /v1/customers/{id}/conversations` ([reference](https://developer.kustomer.com/api/core-resources/create-conversation-for-customer)) ```bash title="cURL" curl -X POST "https://$KUSTOMER_ORG.api.kustomerapp.com/v1/customers/$CUSTOMER_ID/conversations" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Where is my order?", "status": "open", "priority": 3, "tags": ["quickstart"] }' ``` `priority` runs from 1 to 5 and `status` is `open` or `done` on create. Copy `data.id` from the response. ## 5. Add a message Messages carry the actual communication. `channel` and `app` are required: `channel` is the medium, and `app` identifies the integration that produced the message. The text goes in `preview`. `POST /v1/conversations/{id}/messages` ([reference](https://developer.kustomer.com/api/core-resources/create-message-from-conversation)) ```bash title="cURL" curl -X POST "https://$KUSTOMER_ORG.api.kustomerapp.com/v1/conversations/$CONVERSATION_ID/messages" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "chat", "app": "quickstart", "direction": "in", "preview": "Hi! My order #4417 has not arrived yet." }' ``` Open the customer in Kustomer: the conversation and message are on their timeline. _Figure: Kustomer agent workspace showing Jane Doe's timeline with the new conversation and inbound chat message. The customer timeline after the quickstart calls._ ## Next steps - [Making requests](https://developer.kustomer.com/docs/get-started/making-requests): Headers, methods, update semantics and idempotency. - [Data model](https://developer.kustomer.com/docs/concepts/data-model): How every object relates, with an interactive map. - [Sync from a CRM](https://developer.kustomer.com/docs/guides/sync-customers-from-crm): Turn these calls into a production sync. --- # Making requests > Base URLs, headers, HTTP methods, update semantics, lookups by external ID, timestamps and retries for the Kustomer REST API. Source: https://developer.kustomer.com/docs/get-started/making-requests The Kustomer API is a JSON-over-HTTPS REST API. Each resource has a URL, HTTP verbs act on it, and every request is authenticated with a bearer token. _Interactive diagram of the stages an API request passes through: client, org host routing, authentication, authorization by role, rate limiting, validation and the response. Each stage shows the error it can return: 4xx for a region mismatch, 401 for a bad token, 403 for a missing role, 429 when over the rate limit, 400 for an invalid body._ ## Base URL Send requests to your org's API host: ```text https://.api.kustomerapp.com ``` The org-specific host always routes to the region your org lives in, so prefer it to the regional hosts. See [Regions and base URLs](https://developer.kustomer.com/docs/get-started/regions-and-base-urls) for the US, EU and India hosts and when you need them. All requests must use HTTPS. ## Headers | Header | Value | Notes | | --------------- | --------------------------------------- | ------------------------------------------------------------------ | | `Authorization` | `Bearer ` | Required on every request. Note the space after `Bearer`. | | `Content-Type` | `application/json` | Required when you send a body. | | `Accept` | `application/json` | Optional. Responses are always JSON. | | `User-Agent` | `acme-sync/2.3 (+https://acme.example)` | Recommended. Identifies your integration when you contact support. | ## Methods | Method | Use | Typical success | | -------- | ------------------------------------------------- | ------------------------------ | | `GET` | Read a resource or list | `200` | | `POST` | Create a resource, run a search, start a bulk job | `201` (create), `200` (search) | | `PUT` | Update a resource | `200` | | `PATCH` | Update specific attributes | `200` | | `DELETE` | Delete a resource | `204` (most), `200` (some) | ### Update semantics `PUT` and `PATCH` both **merge** by default: send only the attributes you want to change, and the rest stay as they are. Endpoints that support it accept `?replace=true` to replace the whole resource instead. `PUT /v1/customers/{id}` ([reference](https://developer.kustomer.com/api/core-resources/update-customer)) ```bash title="cURL" # Merge: only the tags change curl -X PUT "https://acme.api.kustomerapp.com/v1/customers/$ID" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tags": ["vip", "beta-tester"] }' ``` > **Read each endpoint's notes:** Some attributes have their own rules. For example, on a customer update, omitting `company` unlinks the customer from their company. Sending `tags` sets the full list rather than appending to it. To add or remove single tags on a conversation, use the dedicated [append tags](https://developer.kustomer.com/api/core-resources/append-tags-to-conversation) and [remove tags](https://developer.kustomer.com/api/core-resources/remove-tags-from-conversation) endpoints. ## Looking up by your own IDs Most objects accept an `externalId`, the ID from your system. Store it when you create the record, and you can fetch it later without keeping a Kustomer ID mapping. Lookups use an `attribute=value` path segment: `GET /v1/customers/externalId={externalId}` ([reference](https://developer.kustomer.com/api/core-resources/get-customer-by-external-id)) `GET /v1/customers/email={email}` ([reference](https://developer.kustomer.com/api/core-resources/get-customer-by-email)) `GET /v1/klasses/{name}/externalId={externalId}` ([reference](https://developer.kustomer.com/api/core-resources/get-kobject-by-external-id)) URL-encode the value: `jane+test@example.com` becomes `jane%2Btest%40example.com`. A lookup that finds nothing returns `404`, which makes a simple find-or-create pattern: ```ts title="Node.js" async function upsertCustomer(c: { externalId: string; name: string; email: string }) { const found = await fetch(`${base}/v1/customers/externalId=${encodeURIComponent(c.externalId)}`, { headers }) if (found.status === 404) { return fetch(`${base}/v1/customers`, { method: 'POST', headers, body: JSON.stringify({ name: c.name, externalId: c.externalId, emails: [{ type: 'home', email: c.email }] }), }).then((r) => r.json()) } const { data } = await found.json() return fetch(`${base}/v1/customers/${data.id}`, { method: 'PUT', headers, body: JSON.stringify({ name: c.name }) }).then((r) => r.json()) } ``` ## Common query parameters | Parameter | Example | Effect | | ------------------ | ------------------------ | ----------------------------------------------------------------------------------------------- | | `page`, `pageSize` | `?page=2&pageSize=100` | Page through lists. See [Pagination](https://developer.kustomer.com/docs/concepts/pagination). | | `include` | `?include=company` | Side-load related resources into an `included` array. Supported values are listed per endpoint. | | `fields` | `?fields=name,createdAt` | Return only some attributes, where supported. | | `sort` | `?sort=desc` | Sort direction or field, where supported. | Each endpoint's reference page lists exactly which of these it accepts. ## IDs and timestamps - **IDs** are 24-character hexadecimal strings such as `5822a4a5aa5f92110005d69a`. - **Timestamps** are ISO 8601 in UTC, for example `2026-10-21T14:02:11.925Z`. Send them the same way. - **Imported history.** Set `importedAt` when you load historical conversations, messages or custom objects. Records with `importedAt` skip the per-customer create rate limit, and you can backdate them with `createdAt`. - **Author fields.** `createdAt`, `createdBy`, `modifiedAt` and `modifiedBy` are normally set from the token. Overriding them needs the `org.admin` role. ## Idempotency and retries The REST API has no idempotency-key header. Design writes so they can be repeated safely: - Set `externalId` on everything you create, and look it up before creating (as above). - For bulk jobs, set `_clientRef` on each item to match results to inputs. See [Bulk operations](https://developer.kustomer.com/docs/guides/bulk-operations). - Retry `429` after the time in `x-ratelimit-reset`. Retry `5xx` and network errors with exponential backoff and jitter. - Don't retry other `4xx` responses: fix the request instead. ```ts title="Node.js" async function kfetch(path: string, init: RequestInit = {}, attempt = 0): Promise { const res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...init.headers } }) if (res.status === 429 && attempt < 5) { const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000 await sleep(Math.max(reset - Date.now(), 1000)) return kfetch(path, init, attempt + 1) } if (res.status >= 500 && attempt < 5) { await sleep(2 ** attempt * 500 + Math.random() * 250) return kfetch(path, init, attempt + 1) } return res } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) ``` ## Try it from the reference Every operation in the [API reference](https://developer.kustomer.com/api) has a **Try it** panel. Paste a key, pick your region, and send a real request from your browser. Keys stay in memory and are never stored. Write requests ask you to confirm first. ## Use Postman Download the [Postman collection](https://developer.kustomer.com/api/kustomer.postman_collection.json) and import it into Postman. It has every operation in the reference, grouped by API, with example parameters and bodies. Set the `orgName` and `apiKey` collection variables, and requests go to `https://{orgName}.api.kustomerapp.com` with your key as a bearer token. To use a regional host instead, change `baseUrl`. Optional query parameters and headers are included but turned off. Each request's description lists the roles it requires and links to its reference page. --- # Authentication overview > The ways to authenticate with Kustomer (API keys, OAuth 2.0 and chat keys) and how to choose between them. Source: https://developer.kustomer.com/docs/get-started/authentication Every API request carries a bearer token in the `Authorization` header: ```http GET /v1/users/current HTTP/1.1 Host: acme.api.kustomerapp.com Authorization: Bearer eyJhbGciOi... ``` What changes is where the token comes from and whose identity it carries. ## Choose a method | Method | Acts as | Best for | Lifetime | | ----------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------- | | [API key](https://developer.kustomer.com/docs/get-started/api-keys-and-roles) | A machine user with fixed roles | Server-to-server integrations, scheduled syncs, internal tools | Until it expires or is deleted | | [OAuth 2.0](https://developer.kustomer.com/docs/get-started/oauth) | The signed-in Kustomer user | Tools that act on behalf of individual agents or admins, such as CLIs and AI assistants | Access token about 24 hours, renewed with a refresh token | | [Chat key](https://developer.kustomer.com/docs/chat/web) | An anonymous or identified chat customer | Kustomer Chat in your website or mobile app | Until it expires or is deleted | ```mermaid flowchart TD Q1{"Does the code run on a
customer's device or browser?"} -- Yes --> CHAT["Chat key
(role org.tracking only)"] Q1 -- No --> Q2{"Should actions be attributed
to individual Kustomer users?"} Q2 -- Yes --> OAUTH["OAuth 2.0
authorization code + PKCE"] Q2 -- No --> KEY["API key
with least-privilege roles"] ``` > **Never ship an API key to a browser or mobile app:** API keys can read and write your org's data. Code on customer devices should only ever hold a chat key, which has the single `org.tracking` role and is designed to be public. ## API keys in brief An admin creates keys in **Settings › Security › API Keys**. Each key: - is backed by its own **machine user**, which appears as the author of anything the key creates; - has a set of **roles** chosen at creation that can't be changed afterwards; - can **expire** after a number of days, or never; - can be limited to a **CIDR IP range**; - is shown **once**, when it's created. Read [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles) for the role naming scheme and least-privilege recipes. ## OAuth in brief Kustomer supports the OAuth 2.0 authorization code flow with PKCE. Users sign in at their org's web host (`https://acme.kustomerapp.com/oauth/authorize`), and your app exchanges the code at the API host (`https://acme.api.kustomerapp.com/oauth/token`). The OAuth client is a private app installed in the org, with `redirectUris` in its app definition. _Step-by-step sequence of the OAuth 2.0 authorization code flow with PKCE between your app, the browser, the Kustomer web host and the Kustomer API host: generate a verifier, redirect to /oauth/authorize, user approves, redirect back with a code, POST /oauth/token, receive access and refresh tokens, call the API, refresh before expiry._ The [OAuth 2.0](https://developer.kustomer.com/docs/get-started/oauth) page has the full parameter reference and a working example. ## Checking a token Two endpoints work with any valid token and are handy for health checks: `GET /v1/auth/tokens/current` ([reference](https://developer.kustomer.com/api/access-management/get-current-auth-token)) `GET /v1/users/current` ([reference](https://developer.kustomer.com/api/access-management/get-current-user)) The first describes the token (name, roles, last access). The second returns the user it acts as: the machine user for API keys, or the signed-in person for OAuth. ## Authentication errors | Status | Code | Meaning | Fix | | ------ | -------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `401` | `unauthorized` | Missing, malformed, expired or deleted token, or a request from outside the key's IP restriction | Check the `Bearer ` prefix, the key's status and its CIDR range | | `403` | `forbidden` | The token is valid but lacks a role the operation requires | Create a key with the needed role. Each reference page lists the accepted roles. | | `4xx` | none | `Auth token associated with pod prod2 but request is being handled by prod1` | Use your org host. See [Regions and base URLs](https://developer.kustomer.com/docs/get-started/regions-and-base-urls). | --- # API keys and roles > Create Kustomer API keys, understand machine users and role naming, and pick least-privilege roles for common integrations. Source: https://developer.kustomer.com/docs/get-started/api-keys-and-roles API keys are the standard way to authenticate server-side integrations. Each key is a long-lived bearer token tied to a machine user and a fixed set of roles. ## Create a key #### Open the API keys page Go to **Settings › Security › API Keys** and select **Add API Key**. You need admin access. #### Name it after the integration Use names such as `netsuite-sync-prod` so the machine user is recognizable on timelines and in audit logs. #### Choose roles Select every role the integration needs. **Roles can't be edited later.** To change them, create a new key. #### Set expiry and IP restriction Choose an expiry in days, or no expiry. Optionally, restrict the key to a CIDR range such as `203.0.113.0/24` so it only works from your servers. #### Copy the token The token is displayed once. Put it straight into your secrets manager. _Figure: The Add API Key dialog in Kustomer settings with fields for name, roles, expiry and IP restriction. Settings › Security › API Keys › Add API Key._ ## Machine users Every key is backed by a **machine user**. Anything the key creates or updates shows that user as the author, so agents can see that "netsuite-sync-prod" updated an order rather than a person. You can give the machine user an avatar so its activity is easy to recognize on timelines: `PUT /v1/users/{id}` ([reference](https://developer.kustomer.com/api/access-management/update-user-by-id)) ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/users/$MACHINE_USER_ID" \ -H "Authorization: Bearer $ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "avatarUrl": "https://cdn.example.com/netsuite.png" }' ``` Find the machine user's ID with `GET /v1/users/current` using the key itself. ## How roles are named Roles are dot-separated strings that read from broad to specific: ```text org . permission . customer . create │ │ │ └─ action: read, create, update, delete (or write) │ │ └─ resource: customer, conversation, kobject, klass, ... │ └─ family: permission, user or admin └─ scope: your org ``` | Family | Example | What it grants | | ---------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------ | | `org.permission.*` | `org.permission.customer.create` | One action on one resource. The most precise option and the one to prefer. | | `org.user.*` (legacy) | `org.user.customer.write` | What an agent can do with a resource. `.write` usually covers create, update and delete, but not read. | | `org.admin.*` (legacy) | `org.admin.klass.write` | Admin-level configuration such as Klasses, schedules and metadata. | A role without an action, such as `org.user.brand`, grants every action in that family for the resource. ### Legacy roles `org.user.*` and `org.admin.*` roles predate permission sets. They still work, and existing keys that hold them keep their access. For new keys, prefer `org.permission.*` roles: they grant one action at a time, so a key gets only what it needs. In the API reference, legacy roles are listed after the others under a **Legacy** label, with a dashed outline. Most operations accept either kind, so you can switch a key to permission-set roles without losing access. A few accept only a legacy role, and their Authorization block says so. In [`/api/operations.json`](https://developer.kustomer.com/api/operations.json), each operation's `legacyRoles` lists which of its `roles` are legacy. ### Reading the roles on a reference page Every operation in the [API reference](https://developer.kustomer.com/api) has an **Authorization** block, taken from the checks the API gateway enforces. Most list roles, usually in pairs of a [legacy role](#legacy-roles) and its permission-set equivalent. **Any one** of the listed roles is enough. For example, [Create customer](https://developer.kustomer.com/api/core-resources/create-customer) accepts `org.user.customer.write` or `org.permission.customer.create`. A few operations show something else instead: | Authorization block | Meaning | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Any API key or signed-in user | No specific role. Any valid credential works, for example [Get current user](https://developer.kustomer.com/api/access-management/get-current-user). | | The token itself, or any one of these roles | A token can always read or revoke itself. Acting on another token needs one of the listed roles. | | No authentication | Public. Send no `Authorization` header, as with the public knowledge base endpoints. | The same information is in each operation's Markdown twin and in [`/api/operations.json`](https://developer.kustomer.com/api/operations.json) (`access` and `roles`), so scripts and agents can check a key's roles before calling. ## Least-privilege recipes | Integration | Roles | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Read-only reporting export | `org.permission.customer.read`, `org.permission.conversation.read`, `org.permission.message.read`, `org.permission.search.create` | | CRM customer sync | `org.permission.customer.read`, `org.permission.customer.create`, `org.permission.customer.update`, `org.permission.company.create`, `org.permission.company.update` | | Order lookup via custom objects | `org.permission.kobject.read`, `org.permission.kobject.create`, `org.permission.kobject.update`, `org.permission.customer.read` | | Custom messaging channel | `org.permission.customer.read`, `org.permission.customer.create`, `org.permission.conversation.create`, `org.permission.message.create` | | Webhook and app administration | `org.admin.apps` and the roles for any resources the app reads or writes | Check each endpoint's reference page before relying on a recipe: the listed roles there are authoritative. ## Special-purpose keys | Role | Purpose | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `org.tracking` | Chat SDK keys. A chat key must have **exactly this one role**. Safe to embed in web pages and apps. See [Chat](https://developer.kustomer.com/docs/chat). | | `org.hooks` | Hook access, for systems that post into Kustomer hooks. See [Inbound webhooks](https://developer.kustomer.com/docs/events/inbound-webhooks). | List the top-level roles available to your org with: `GET /v1/auth/roles` ([reference](https://developer.kustomer.com/api/access-management/get-auth-roles)) ## Rotating keys Keys can't be edited, so rotation means replacing: 1. Create a new key with the same roles. 2. Deploy it alongside the old one, then switch traffic over. 3. Watch the old key's last-used time in **Settings › Security › API Keys** (also in `lastAccessedAt` from `GET /v1/auth/tokens/current`) until it stops changing. 4. Delete the old key. > **Expiring keys:** For anything that isn't a long-running production integration, set an expiry. A forgotten key that expired is not a key that leaked. --- # OAuth 2.0 > Authenticate users with the OAuth 2.0 authorization code flow and PKCE, exchange and refresh tokens, and request scopes. Source: https://developer.kustomer.com/docs/get-started/oauth Use OAuth when your software should act as an individual Kustomer user rather than as a shared machine user. Actions appear under that person's name and are limited to what they're allowed to do. The [`kusty` CLI](https://developer.kustomer.com/docs/ai/kusty) uses this flow for `kusty auth login`. Kustomer implements the **authorization code** grant with **PKCE** (`S256`) and **refresh tokens**. _Step-by-step sequence of the OAuth 2.0 authorization code flow with PKCE between your app, the browser, the Kustomer web host and the Kustomer API host._ ## Endpoints Both endpoints are on your customer's org, not on a global host: | Step | URL | | --------- | ----------------------------------------------- | | Authorize | `https://.kustomerapp.com/oauth/authorize` | | Token | `https://.api.kustomerapp.com/oauth/token` | Ask the user for their org name (the subdomain they sign in at) before you start the flow. ## 1. Set up an OAuth client An OAuth client is an app installed in the org. Its app definition declares the allowed `redirectUris`: ```json title="app definition (excerpt)" { "app": "acme_assistant", "version": "1.0.0", "title": "Acme Assistant", "visibility": "private", "redirectUris": ["https://assistant.acme.example/oauth/callback"] } ``` 1. **Register** the app definition. The response includes the app's `clientId`. 2. **Install** it in the org. Authorization only succeeds once the app is installed there, not merely registered. 3. **Mint a client secret** for confidential clients (servers that can keep a secret). The secret is returned once. See [Apps platform › OAuth](https://developer.kustomer.com/docs/apps/oauth) for the registration and secret endpoints, and [Register and install](https://developer.kustomer.com/docs/apps/register-and-install) for the full app lifecycle. > **Native and CLI apps:** For apps on a user's machine, register loopback redirect URIs such as `http://localhost:53682/callback`. Register a few ports so you have a fallback if one is busy. This is what `kusty` does. ## 2. Send the user to authorize Generate a random `code_verifier` and `state`, store them for this login attempt, and redirect: ```text https://acme.kustomerapp.com/oauth/authorize ?client_id=YOUR_CLIENT_ID &redirect_uri=https%3A%2F%2Fassistant.acme.example%2Foauth%2Fcallback &response_type=code &scope=org.permission.customer%20org.permission.conversation%20offline_access &state=Xy9cPq... &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 ``` **Authorize parameters** The user signs in to their org and approves. Kustomer redirects back to your `redirect_uri` with `code` and `state`, or with `error` and `error_description` if they declined. ## 3. Exchange the code ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$CODE" \ --data-urlencode "redirect_uri=https://assistant.acme.example/oauth/callback" \ --data-urlencode "code_verifier=$CODE_VERIFIER" \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "client_secret=$CLIENT_SECRET" ``` The body is form-encoded, not JSON. A successful response: ```json title="200 OK" { "access_token": "eyJhbGciOi...", "refresh_token": "def50200...", "token_type": "Bearer", "expires_in": 86400, "scope": "org.permission.customer org.permission.conversation offline_access" } ``` Errors use the standard OAuth shape: `{"error": "invalid_grant", "error_description": "..."}`. ## 4. Call the API Send the access token exactly like an API key: ```bash curl "https://acme.api.kustomerapp.com/v1/users/current" -H "Authorization: Bearer $ACCESS_TOKEN" ``` Access tokens last about 24 hours. Use `expires_in` rather than hard-coding a lifetime. ## 5. Refresh ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=$REFRESH_TOKEN" \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "client_secret=$CLIENT_SECRET" ``` > **Refresh needs a client secret, and refresh tokens rotate:** Kustomer accepts PKCE without a secret for the first login, but the `refresh_token` grant requires client credentials. A client with only a `client_id` can log in but gets `invalid_client` when it tries to refresh. > > Each refresh returns a **new** refresh token. Store it before using the new access token, and don't retry a refresh that failed with a `4xx`: the old token may already be spent. ## Complete example A minimal Node.js login for a local tool, with no dependencies beyond the standard library: ```ts title="Node.js" import { createHash, randomBytes } from 'node:crypto' import { createServer } from 'node:http' const org = 'acme' const clientId = process.env.KUSTOMER_CLIENT_ID! const clientSecret = process.env.KUSTOMER_CLIENT_SECRET! const redirectUri = 'http://localhost:53682/callback' const b64url = (b: Buffer) => b.toString('base64url') const verifier = b64url(randomBytes(32)) const challenge = b64url(createHash('sha256').update(verifier).digest()) const state = b64url(randomBytes(16)) const authorize = new URL(`https://${org}.kustomerapp.com/oauth/authorize`) authorize.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'org.permission.customer org.permission.conversation offline_access', state, code_challenge: challenge, code_challenge_method: 'S256', }).toString() console.log('Open this URL to sign in:\n', authorize.href) const code = await new Promise((resolve, reject) => { const server = createServer((req, res) => { const url = new URL(req.url!, redirectUri) if (url.searchParams.get('state') !== state) return reject(new Error('state mismatch')) res.end('Signed in. You can close this tab.') server.close() resolve(url.searchParams.get('code')!) }).listen(53682) }) const tokens = await fetch(`https://${org}.api.kustomerapp.com/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: verifier, client_id: clientId, client_secret: clientSecret, }), }).then((r) => r.json()) const me = await fetch(`https://${org}.api.kustomerapp.com/v1/users/current`, { headers: { Authorization: `Bearer ${tokens.access_token}` }, }).then((r) => r.json()) console.log('Signed in as', me.data.attributes.name) ``` ## Scopes Scopes are Kustomer roles, written without an action to cover every action on a resource: `org.permission.customer`, `org.permission.conversation`, `org.permission.message`, `org.permission.user`. Add `offline_access` to get a refresh token. A token never has more access than the user who approved it, whatever scopes you request. See [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles#how-roles-are-named) for how role names are built. --- # Regions and base URLs > Kustomer's US, EU and India regions, the org-specific API host, and the other hostnames an integration talks to. Source: https://developer.kustomer.com/docs/get-started/regions-and-base-urls Kustomer runs in three regions: the United States, the European Union and India. Every org lives in exactly one, and its data stays there. API keys and OAuth tokens only work in the region where they were issued. ## Use your org host The simplest rule: **always call your org's own API host.** ```text https://.api.kustomerapp.com ``` It routes to the right region automatically, so the same code works for US, EU and India orgs. Your org name is the subdomain you sign in at: `acme.kustomerapp.com` means `acme`. ## Regional hosts The API reference also lists the regional hosts. They're useful when you don't know the org name, for example in multi-tenant tools that only store a token. | Region | API host | | -------------- | ----------------------------------- | | United States | `https://api.kustomerapp.com` | | European Union | `https://api.prod2.kustomerapp.com` | | India | `https://api.prod4.kustomerapp.com` | > **Region mismatch:** Sending a token to the wrong region fails with an error like `Auth token associated with pod prod2 but request is being handled by prod1`. The fix is to use the org host, or the regional host that matches the org. The **Try it** panel in the [API reference](https://developer.kustomer.com/api) has a region selector with the same options: your org host, US, EU and India. ## Other hostnames | Host | Used for | | ----------------------------------- | ------------------------------------------------------------------ | | `https://.kustomerapp.com` | The Kustomer web app, and the OAuth `/oauth/authorize` endpoint | | `https://.api.kustomerapp.com` | REST API and the OAuth `/oauth/token` endpoint | | `https://cdn.kustomerapp.com` | Static assets, including the web chat SDK at `/chat-web/widget.js` | If your network filters outbound traffic, allow HTTPS to `*.kustomerapp.com`. ## Multi-region integrations If you build for many Kustomer orgs, for example a marketplace app: - Store the org name with each customer's credentials, and build the base URL from it. - Never send one org's token to another org's host. - Treat region as a property of the org, not of your deployment. A single deployment can serve US, EU and India orgs. --- # Core concepts > The data model, request and response format, and platform limits that every Kustomer integration builds on. Source: https://developer.kustomer.com/docs/concepts Every Kustomer API, from the REST endpoints to webhooks, workflows and apps, works with the same records and follows the same conventions. Read these pages once and the rest of the platform is predictable. ## Data model Kustomer organizes everything around the **customer**. Conversations, messages, notes and custom objects all hang off a customer and appear on their timeline. - [Data model overview](https://developer.kustomer.com/docs/concepts/data-model): How customers, companies, conversations, messages, notes, custom objects, users and teams relate. - [Conversations and messages](https://developer.kustomer.com/docs/concepts/conversations-and-messages): Status, assignment and lifecycle; message channels, direction and threading. - [Custom objects (KObjects)](https://developer.kustomer.com/docs/concepts/custom-objects): Model orders, bookings and devices from your systems and attach them to timelines. - [Custom attributes](https://developer.kustomer.com/docs/concepts/custom-attributes): Typed fields on standard and custom objects, named with type suffixes like `Str` and `Num`. ## API conventions - [Request and response format](https://developer.kustomer.com/docs/concepts/json-api-format): The `data` envelope, relationships, includes and lists. - [Pagination](https://developer.kustomer.com/docs/concepts/pagination): `page` and `pageSize`, and why you should follow `links.next`. - [Search](https://developer.kustomer.com/docs/concepts/search): Filters, operators, dates and sorting across every object. - [Rate limits](https://developer.kustomer.com/docs/concepts/rate-limits): The org-wide per-minute budget and the `x-ratelimit-*` headers. - [Errors](https://developer.kustomer.com/docs/concepts/errors): Status codes, error shapes, and which errors to retry. - [Versioning](https://developer.kustomer.com/docs/concepts/versioning): How the API is versioned and how deprecations are announced. ## The short version If you only remember five things: 1. **Requests go to your org's host**, `https://.api.kustomerapp.com`, with `Authorization: Bearer `. See [regions and base URLs](https://developer.kustomer.com/docs/get-started/regions-and-base-urls). 2. **Responses wrap records in `data`**, with fields under `attributes` and links to other records under `relationships`. See [request and response format](https://developer.kustomer.com/docs/concepts/json-api-format). 3. **Lists are paged.** Follow `links.next` until it's `null` instead of computing page numbers. See [pagination](https://developer.kustomer.com/docs/concepts/pagination). 4. **The request budget is shared by the whole org**, across every API key and app. Read the `x-ratelimit-*` headers and back off on `429`. See [rate limits](https://developer.kustomer.com/docs/concepts/rate-limits). 5. **Every change emits an event** such as `kustomer.conversation.update`, which you can receive with [webhooks](https://developer.kustomer.com/docs/events) or act on in [workflows](https://developer.kustomer.com/docs/workflows). --- # Data model overview > How customers, companies, conversations, messages, notes, custom objects, users and teams relate in Kustomer, and which endpoints work with each. Source: https://developer.kustomer.com/docs/concepts/data-model Kustomer is organized around the **customer**. Instead of a ticket queue where each request stands alone, every conversation, message, order and event hangs off one customer record and appears on one timeline. That shape explains most of the API: many endpoints are nested under `/v1/customers/{id}`, and most objects carry a `customer` relationship. _Interactive map of the Kustomer data model. Customer is at the center. A customer belongs to a Company and has many Conversations. Conversations have Messages and Notes; Messages have Attachments. Customers and companies own KObjects (custom objects), each an instance of a Klass. Conversations are assigned to Users and Teams. Selecting an object shows its key attributes and the endpoints that create and read it._ ## The objects | Object | What it represents | Belongs to | Base path | | ------------ | ----------------------------------------------------------- | ----------------------------- | -------------------- | | Customer | A person you support | Optionally a company | `/v1/customers` | | Company | An organization customers belong to | Optionally a parent company | `/v1/companies` | | Conversation | A thread with one customer, with status, assignment and SLA | A customer | `/v1/conversations` | | Message | One inbound or outbound communication on a channel | A conversation and a customer | `/v1/messages` | | Note | An internal comment visible only to your team | A conversation | `/v1/notes` | | Draft | An unsent outbound message | A conversation | `/v1/drafts` | | Attachment | A file on a message, note or draft | Its parent | `/v1/attachments` | | KObject | A record of a custom type, such as an order or booking | A customer or company | `/v1/klasses/{name}` | | Klass | The schema for a type of KObject | Your org | `/v1/klasses` | | User | An agent, admin or machine user | Your org | `/v1/users` | | Team | A group of users | Your org | `/v1/teams` | Every object has a 24-character `id` assigned by Kustomer, and most accept an `externalId` you choose. Use `externalId` to link records to your own systems. ## The timeline The customer timeline is the agent's view of this graph: conversations with their messages and notes, KObjects such as orders and subscriptions, and tracked events, merged and sorted by time. _Figure: A customer timeline in Kustomer showing an email conversation, an order custom object card and a chat conversation in date order. A customer's timeline combines conversations, custom objects and events._ That's why you should attach data to the right customer: an order KObject created under the customer shows up next to the conversation where the customer asks about it, and workflows can use both. ## Standard and custom attributes Every object has **standard attributes** defined by Kustomer, such as `name`, `emails` and `status`. Objects also accept **custom attributes** under a `custom` key, typed by their name suffix: ```json { "name": "Jane Doe", "custom": { "loyaltyTierStr": "gold", "lifetimeValueNum": 1840.5, "lastOrderAt": "2026-10-02T15:04:05Z", "marketingOptInBool": true } } ``` [Custom attributes](https://developer.kustomer.com/docs/concepts/custom-attributes) covers the suffixes and how to give each attribute a display name, type and options. ## Relationships in responses Responses describe links to other objects in a `relationships` block, using IDs: ```json { "data": { "type": "conversation", "id": "58228e05d4e96711004eccb7", "attributes": { "name": "Where is my order?", "status": "open" }, "relationships": { "customer": { "data": { "type": "customer", "id": "5822a4a5aa5f92110005d69a" }, "links": { "self": "/v1/customers/5822a4a5aa5f92110005d69a" } } } } } ``` In requests, you set a relationship with a plain ID attribute, such as `"customer": "5822a4a5aa5f92110005d69a"` or `"company": "..."`. See [Request and response format](https://developer.kustomer.com/docs/concepts/json-api-format). ## Where to go next - [Conversations and messages](https://developer.kustomer.com/docs/concepts/conversations-and-messages): Status, assignment, channels, and how messages thread. - [Custom objects](https://developer.kustomer.com/docs/concepts/custom-objects): Model orders, bookings and devices with Klasses and KObjects. - [Search](https://developer.kustomer.com/docs/concepts/search): Query across customers, conversations, messages and custom objects. --- # Conversations and messages > Conversation status, assignment and lifecycle; message channels, direction and threading; notes and drafts. Source: https://developer.kustomer.com/docs/concepts/conversations-and-messages A **conversation** is a thread of communication with one customer. It holds the workflow state agents care about: status, priority, assignment, queue, tags and SLA. A **message** is one communication inside it: an email, a chat line, an SMS, a call, or a message from your own system. ```mermaid flowchart LR C[Customer] --> CV1[Conversation
status: open] C --> CV2[Conversation
status: done] CV1 --> M1[Message
in · email] CV1 --> M2[Message
out · email] CV1 --> N1[Note
internal] CV1 --> D1[Draft
unsent] CV2 --> M3[Message
in · chat] ``` ## Conversation lifecycle _Interactive state machine of conversation status with three states: open, snoozed and done. A new conversation starts as open when created or when a customer message arrives, and can also be created as done through the API. An agent snoozing moves open to snoozed; the snooze ending or the customer replying moves it back to open; marking it done moves open to done; and a customer reply reopens a done conversation. Alongside, a timeline of inbound and outbound messages with their channels shows how messageCount, outboundMessageCount, channels, lastMessageIn and the snooze status and time change with each event._ | Status | Meaning | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `open` | Needs attention. Counts toward queues and SLAs. | | `snoozed` | Parked until a time or event. Set with `status: "snoozed"` and a `snooze` object, using an option from your org's [snooze settings](https://developer.kustomer.com/api/core-resources/get-snoozes). | | `done` | Resolved. A new inbound message usually reopens it. | New conversations can be created as `open` or `done`. Orgs can also define **sub-statuses** (`subStatus`) to add detail inside a status, such as "Waiting on warehouse". ## Key conversation attributes **Conversation** Read-only counters and timestamps come back in responses: `messageCount`, `noteCount`, `outboundMessageCount`, `firstResponse`, `lastMessageIn`, `sla`, `satisfaction` and `channels` (every channel used in the thread). `POST /v1/customers/{id}/conversations` ([reference](https://developer.kustomer.com/api/core-resources/create-conversation-for-customer)) `PUT /v1/conversations/{id}` ([reference](https://developer.kustomer.com/api/core-resources/update-conversation)) `POST /v1/conversations/{id}/tags` ([reference](https://developer.kustomer.com/api/core-resources/append-tags-to-conversation)) ## Messages **Message** ### Two ways to create a message **Into an existing conversation** `POST /v1/conversations/{id}/messages` ([reference](https://developer.kustomer.com/api/core-resources/create-message-from-conversation)) Use this when you already track the conversation, for example because you stored its ID or `externalId`. **Through the customer** `POST /v1/customers/{id}/messages` ([reference](https://developer.kustomer.com/api/core-resources/create-message-by-customer-id)) Pass `conversation` to thread the message into an existing conversation. Leave it out and Kustomer creates a new conversation for the message. ```json title="Request body" { "channel": "chat", "app": "acme-messenger", "direction": "in", "preview": "Can I change the delivery address on order #4417?", "externalId": "msg_01HF3Q8Y2V", "sentAt": "2026-10-21T14:02:11Z" } ``` > **Messages created by the API don't send anything:** Creating a message records it on the timeline. An outbound message created through the API is **not** delivered to the customer by Kustomer's email, SMS or chat channels. It's a record of something your system sent. To let agents reply through your channel, see [Bring messages from a custom channel](https://developer.kustomer.com/docs/guides/custom-channel-messages). ## Notes Notes are internal comments on a conversation. Customers never see them. They support `@mentions` of users or teams through `userMentions` (up to 20), and attachments. `POST /v1/conversations/{id}/notes` ([reference](https://developer.kustomer.com/api/core-resources/create-note-within-conversation)) ```json title="Request body" { "body": "Warehouse confirmed the replacement shipped today.", "userMentions": [{ "user": "58190b991f2932100010d683" }] } ``` ## Drafts Drafts are outbound messages that haven't been sent. Integrations use them to prepare replies, such as an AI-suggested answer, for an agent to review and send. `POST /v1/customers/{id}/drafts` ([reference](https://developer.kustomer.com/api/core-resources/create-draft-for-customer)) ## Rate limits that apply - A single client can create up to **120 messages, conversations, notes, drafts or custom objects per customer per minute**. Records with `importedAt` are exempt. - A conversation or message can be **updated 100 times in 10 minutes**. See [Rate limits](https://developer.kustomer.com/docs/concepts/rate-limits) for the full picture. --- # Custom objects (KObjects) > Model orders, bookings, devices and other records from your systems with Klasses and KObjects, and attach them to customer timelines. Source: https://developer.kustomer.com/docs/concepts/custom-objects Custom objects bring records from your systems into Kustomer as first-class data. An e-commerce order, a flight booking, a subscription or a smart-home device can live on the customer's timeline, power search and workflows, and show up in agent views. Two concepts work together: - A **Klass** is a type, with a name, icon, color and typed attributes. For example, `ecomm-order`. - A **KObject** is one record of that type, such as order `#4417` for Jane Doe. ```mermaid flowchart LR K["Klass
ecomm-order
icon, color, attributes"] -. defines .-> O1["KObject
Order #4417"] K -. defines .-> O2["KObject
Order #4502"] O1 --> C[Customer
Jane Doe] O2 --> C C --> T[(Timeline)] O1 --> T O2 --> T ``` ## Choose a KObject or a custom attribute | If you need to store | Use | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | One value about a customer, such as a loyalty tier | A [custom attribute](https://developer.kustomer.com/docs/concepts/custom-attributes) on the customer | | One value about a conversation, such as an order number | A custom attribute on the conversation | | Many records of the same shape, such as orders or shipments | A Klass with KObjects | | A structured record with several fields that agents review | A Klass with KObjects | ## Define a Klass Apps usually ship their Klasses in the [app definition](https://developer.kustomer.com/docs/apps/klasses), so they're installed consistently in every org. You can also create one directly: `POST /v1/klasses` ([reference](https://developer.kustomer.com/api/core-resources/create-klass)) ```json title="Request body" { "name": "ecomm-order", "displayName": "Order", "icon": "basket", "color": "#64943E" } ``` - `name` is the system name used in API paths. It must be unique in the org and use only letters, digits, `_` and `-` (up to 255 characters). Prefix it with your app or company name, such as `ecomm-order`, to avoid collisions. - `icon` is a [Material Design Icons](https://pictogrammers.com/library/mdi/) name such as `basket`, `truck-outline` or `airplane`. - `color` is any CSS color. Then give the attributes types and display names by setting metadata for the Klass: `PUT /v1/metadata/{resource}` ([reference](https://developer.kustomer.com/api/core-resources/update-custom-attribute-metadata)) ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/metadata/kobject.ecomm-order" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "orderNumberStr": { "displayName": "Order number" }, "orderStatusStr": { "displayName": "Status", "enumeration": ["placed", "shipped", "delivered", "returned"] }, "totalPriceNum": { "displayName": "Total" }, "orderedAt": { "displayName": "Ordered" }, "trackingUrl": { "displayName": "Tracking link" } } }' ``` The suffix on each name (`Str`, `Num`, `At`, `Url`) sets its type. See [Custom attributes](https://developer.kustomer.com/docs/concepts/custom-attributes) for every suffix and option. ## Create KObjects Create a KObject under the customer it belongs to: `POST /v1/customers/{id}/klasses/{name}` ([reference](https://developer.kustomer.com/api/core-resources/create-customer-kobject-custom-object)) ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/customers/$CUSTOMER_ID/klasses/ecomm-order" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Order #4417", "description": "2 items · Express shipping", "externalId": "order-4417", "custom": { "orderNumberStr": "4417", "orderStatusStr": "shipped", "totalPriceNum": 129.9, "orderedAt": "2026-10-18T09:12:00Z", "trackingUrl": "https://track.example.com/1Z999" }, "data": { "lineItems": [{ "sku": "TEE-BLK-M", "qty": 2 }] }, "tags": ["express"] }' ``` **KObject** You can also create a KObject at `/v1/klasses/{name}` with `customer` or `company` in the body, or load many at once with the [bulk endpoint](https://developer.kustomer.com/api/core-resources/bulk-create-kobjects-custom-objects). ## Read and update `GET /v1/klasses/{name}/externalId={externalId}` ([reference](https://developer.kustomer.com/api/core-resources/get-kobject-by-external-id)) `PUT /v1/klasses/{name}/{id}` ([reference](https://developer.kustomer.com/api/core-resources/update-kobject-custom-object-by-id)) `GET /v1/customers/{id}/klasses/{name}` ([reference](https://developer.kustomer.com/api/core-resources/find-customer-kobjects-custom-objects)) A typical sync looks up by `externalId`, then creates or updates: ```ts title="Node.js" async function upsertOrder(customerId: string, order: Order) { const body = JSON.stringify({ title: `Order #${order.number}`, externalId: `order-${order.number}`, custom: { orderNumberStr: order.number, orderStatusStr: order.status, totalPriceNum: order.total }, }) const found = await fetch(`${base}/v1/klasses/ecomm-order/externalId=order-${order.number}`, { headers }) if (found.status === 404) { return fetch(`${base}/v1/customers/${customerId}/klasses/ecomm-order`, { method: 'POST', headers, body }) } const { data } = await found.json() return fetch(`${base}/v1/klasses/ecomm-order/${data.id}`, { method: 'PUT', headers, body }) } ``` ## Show KObjects to agents KObjects appear on the customer timeline automatically, using the Klass icon, color, `title` and `description`. For a richer layout, such as a table of line items with a "Track package" button, build a [KView](https://developer.kustomer.com/docs/apps/kviews) for the Klass. _Figure: An ecomm-order KObject card on a customer timeline with order number, status, total and a tracking link. A KObject on the timeline, rendered with a KView\._ ## Limits - A single client can create up to **120 KObjects per customer per minute**. Records with `importedAt` are exempt. - Custom string attributes (`Str`) hold up to 1,024 characters. The end-to-end [orders integration guide](https://developer.kustomer.com/docs/guides/kobject-orders-integration) puts all of this together. --- # Custom attributes > Add typed fields to customers, companies, conversations, messages and custom objects using name suffixes and attribute metadata. Source: https://developer.kustomer.com/docs/concepts/custom-attributes Custom attributes are your own fields on Kustomer objects. They live under a `custom` key, and the **suffix** of each name sets its type: ```json { "custom": { "loyaltyTierStr": "gold", "lifetimeValueNum": 1840.5, "lastOrderAt": "2026-10-02T15:04:05Z", "marketingOptInBool": true, "accountUrl": "https://admin.acme.example/customers/10293", "notesTxt": "Prefers email. Allergic to latex." } } ``` ## Suffixes | Suffix | Type | Example name | Example value | | ------ | ------------------------------------ | -------------------- | ---------------------------- | | `Str` | Short string, up to 1,024 characters | `loyaltyTierStr` | `"gold"` | | `Txt` | Text, shown as a multi-line field | `notesTxt` | `"Prefers email."` | | `Num` | Number | `lifetimeValueNum` | `1840.5` | | `At` | Date and time (ISO 8601) | `lastOrderAt` | `"2026-10-02T15:04:05Z"` | | `Bool` | Boolean | `marketingOptInBool` | `true` | | `Url` | URL | `accountUrl` | `"https://..."` | | `Id` | Reference to another object | `primaryOrderId` | `"5822a4a5aa5f92110005d69a"` | Every type also accepts `null` to clear the value. Use camelCase and keep names stable: the system name is what the API, workflows and search use, even if an admin later changes the display name. _Type a label such as 'Order number' and pick a type suffix (Str, Txt, Num, At, Bool, Url or Id). The label is split into words, camelCased and given the suffix, so 'Order number' with Str becomes orderNumberStr, a short string of up to 1,024 characters. The name then appears in a PATCH /v1/customers/{id} body under custom, in a mock customer card labelled with the default display name 'Order Number' (title-cased, without the suffix), and as customer_custom_orderNumberStr in search and workflow conditions. Mistakes are shown too: a name with no suffix or a spelled-out type such as orderNumberString, or a display name used as the key, fails with 400 bad_request; PascalCase is flagged as not camelCase; sending null clears the value; and Id attributes need metadata with a target before you write values._ ## Which objects support them Customers, companies, conversations, messages, notes, users, KObjects and tracked events. Each object type has its own set of attributes, configured separately. ## Attribute metadata Metadata tells Kustomer how to present and validate each attribute: its display name, type, allowed values, and whether it's sensitive. Admins can edit it in **Settings › Platform › Klasses**, or you can manage it through the API. `GET /v1/metadata/{resource}` ([reference](https://developer.kustomer.com/api/core-resources/get-custom-attribute-metadata)) `PUT /v1/metadata/{resource}` ([reference](https://developer.kustomer.com/api/core-resources/update-custom-attribute-metadata)) `resource` is one of `customer`, `company`, `conversation`, `message`, `note`, `user`, `kobject`, `kobject.`, `event` or `event.`. ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/metadata/customer" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "loyaltyTierStr": { "displayName": "Loyalty tier", "enumeration": ["bronze", "silver", "gold"] }, "lifetimeValueNum": { "displayName": "Lifetime value" }, "primaryOrderId": { "displayName": "Primary order", "target": "kobject.ecomm-order" } } }' ``` ### Metadata properties **properties.\** A single request can set up to 100 properties. > **Configure Id attributes before you use them:** An attribute ending in `Id` must have metadata with a `target` before you write values to it. Kustomer validates that each value refers to an existing object of the target type. > **Inherited sensitivity:** Some standard attributes inherit sensitivity settings: `customer.externalId` follows `customer.externalIds`, and `customer.firstName`, `customer.lastName` and `customer.displayName` follow `customer.name`. ## Using custom attributes elsewhere - **Search.** Custom attributes are searchable with names like `customer_custom_loyaltyTierStr` or `kobject_custom_ecomm-order_orderStatusStr`. See [Search](https://developer.kustomer.com/docs/concepts/search). - **Workflows and business rules.** Use them in conditions (for example `customer_custom_loyaltyTierStr` equals `gold`) and set them in actions. See [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions). - **Apps.** Declare them in an app definition so they're configured on install. See [Klasses](https://developer.kustomer.com/docs/apps/klasses). --- # Request and response format > The JSON shapes the Kustomer API accepts and returns, covering request bodies, the data envelope, relationships, side-loaded includes, lists and errors. Source: https://developer.kustomer.com/docs/concepts/json-api-format The Kustomer API uses a JSON:API-style envelope for responses and plain JSON for request bodies. Once you know the four shapes on this page, you can read any response in the reference. ## Requests: plain attributes Request bodies are flat JSON objects of attributes. Relationships are set with an ID: ```json title="POST /v1/customers/{id}/conversations" { "name": "Where is my order?", "status": "open", "priority": 3, "assignedTeams": ["5cb3a1b7c9e77c0013e5f2d1"], "custom": { "orderNumberStr": "4417" } } ``` Don't wrap request bodies in `data` or `attributes`. Send `Content-Type: application/json`. ## Single resources A single resource comes back in `data`, with: - `type`: the resource type, such as `customer`, `conversation` or `kobject`; - `id`: Kustomer's ID; - `attributes`: the fields, including `custom`; - `relationships`: links to related resources by type and ID; - `links.self`: the resource's own path. ```json title="200 OK" { "data": { "type": "conversation", "id": "58228e05d4e96711004eccb7", "attributes": { "name": "Where is my order?", "status": "open", "priority": 3, "messageCount": 2, "custom": { "orderNumberStr": "4417" }, "createdAt": "2026-10-21T14:02:11.925Z", "updatedAt": "2026-10-21T14:05:40.118Z" }, "relationships": { "customer": { "links": { "self": "/v1/customers/5822a4a5aa5f92110005d69a" }, "data": { "type": "customer", "id": "5822a4a5aa5f92110005d69a" } }, "createdBy": { "links": { "self": "/v1/users/58190b991f2932100010d683" }, "data": { "type": "user", "id": "58190b991f2932100010d683" } } }, "links": { "self": "/v1/conversations/58228e05d4e96711004eccb7" } } } ``` _An annotated, syntax-highlighted conversation response from GET /v1/conversations/{id}. Each part is outlined and explained: data, type, id, attributes (including custom and read-only fields such as messageCount), relationships to a customer and a createdBy user by type and id, and links.self. With ?include=customer, a top-level included array holds the customer, and selecting the customer relationship draws a connector to the included item with the same type and id. Toggles switch to the plain request body (flat attributes with no data or attributes wrapper, where name, status, priority and custom move into data.attributes in the response) and to a list response with meta, paging links and a data array._ ## Lists Lists return an array in `data`, plus `meta` and `links` for paging: ```json title="200 OK" { "meta": { "pageSize": 100, "page": 1 }, "links": { "self": "/v1/brands?page=1&pageSize=100", "first": "/v1/brands?page=1&pageSize=100", "prev": null, "next": null }, "data": [ { "type": "brand", "id": "5daf7921124be4f2960cb196", "attributes": { "name": "default", "default": true } } ] } ``` Some lists add `total` and `totalPages` to `meta`. See [Pagination](https://developer.kustomer.com/docs/concepts/pagination). ## Includes Endpoints that support `include` can side-load related resources into a top-level `included` array, saving extra requests: ```bash curl "https://acme.api.kustomerapp.com/v1/customers/email=jane.doe%40example.com?include=company" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` ```json title="200 OK (excerpt)" { "data": { "type": "customer", "id": "5822a4a5aa5f92110005d69a", "relationships": { "company": { "data": { "type": "company", "id": "5bd7b2f6f1d4b10013e8a7c2" } } } }, "included": [ { "type": "company", "id": "5bd7b2f6f1d4b10013e8a7c2", "attributes": { "name": "Acme Corp" } } ] } ``` Match items in `included` to relationships by `type` and `id`. Each endpoint's reference page lists the values its `include` parameter supports. ```ts title="Node.js" function resolve(doc: { included?: Resource[] }, rel?: { data?: { type: string; id: string } }) { return rel?.data && doc.included?.find((r) => r.type === rel.data!.type && r.id === rel.data!.id) } const company = resolve(body, body.data.relationships.company) ``` ## Errors Errors return an `errors` array: ```json title="400 Bad Request" { "errors": [ { "code": "bad_request", "message": "Invalid request body or parameters.", "meta": {} } ] } ``` A few endpoints use the fuller JSON:API error object, with `status`, `title`, `detail` and `source`. Parse defensively: read `code`, then `message`, then `detail`, then `title`. See [Errors](https://developer.kustomer.com/docs/concepts/errors). ## Field conventions | Convention | Example | | --------------------------------------------------- | ---------------------------------- | | camelCase attribute names | `createdAt`, `assignedUsers` | | ISO 8601 UTC timestamps | `"2026-10-21T14:02:11.925Z"` | | 24-character hexadecimal IDs | `"5822a4a5aa5f92110005d69a"` | | Custom attributes under `custom` with a type suffix | `"custom": { "tierStr": "gold" }` | | Server-managed fields are read-only | `messageCount`, `updatedAt`, `rev` | --- # Pagination > Page through Kustomer list endpoints with page and pageSize, follow links.next, and handle cursor-based and very large result sets. Source: https://developer.kustomer.com/docs/concepts/pagination List endpoints return results a page at a time. Most use **page numbers**; a few use **cursors**. Either way, the safest approach is to follow `links.next` until it's `null`. ## Page-number pagination Pass `page` (starting at 1) and `pageSize`: ```bash curl "https://acme.api.kustomerapp.com/v1/customers?page=2&pageSize=100" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` ```json title="200 OK" { "meta": { "pageSize": 100, "page": 2, "total": 4210, "totalPages": 43 }, "links": { "self": "/v1/customers?page=2&pageSize=100", "first": "/v1/customers?page=1&pageSize=100", "prev": "/v1/customers?page=1&pageSize=100", "next": "/v1/customers?page=3&pageSize=100" }, "data": [ ... ] } ``` | Field | Meaning | | ----------------------------------------- | ------------------------------------------------------ | | `meta.page`, `meta.pageSize` | The page you got and its size | | `meta.total`, `meta.totalPages` | Total matches and pages, on endpoints that report them | | `links.next` | Path to the next page, or `null` on the last page | | `links.prev`, `links.first`, `links.last` | Other pages, where available | _An animated walk through a 47-record customer list. The reader picks a pageSize of 5, 10 or 20 and follows links.next one request at a time: each GET goes out, the highlighted page window slides along the record strip, and the response shows meta (pageSize, page, total, totalPages) and links (self, first, prev, next), with prev null on page 1 and next null on the last page, where the loop stops. A cursor toggle switches to an audit-log list paged with count and a before cursor carried inside links.next, which has no page numbers to jump to. A panel below shows that search stops at the first 10,000 matches (page times pageSize), so large result sets must be split into updated_at windows._ ### Page sizes `pageSize` limits vary by endpoint. The reference page for each list shows its default and maximum. | Endpoints | Typical maximum | | ------------------ | --------------- | | Most lists | 100 | | Users and teams | 500 | | Search | 500 | | Conversation times | 1,000 | Request the largest page size you can handle. Fewer, larger pages use less of your [rate limit](https://developer.kustomer.com/docs/concepts/rate-limits). ### Iterate every page ```ts title="Node.js" async function* listAll(path: string) { let next: string | null = `${path}${path.includes('?') ? '&' : '?'}pageSize=100` while (next) { const res = await kfetch(next) // retries 429s; see Making requests const body = await res.json() yield* body.data next = body.links?.next ?? null } } for await (const customer of listAll('/v1/customers')) { console.log(customer.id, customer.attributes.name) } ``` ```python title="Python" def list_all(path): url = f"{base}{path}{'&' if '?' in path else '?'}pageSize=100" while url: body = requests.get(url, headers=headers).json() yield from body["data"] nxt = body.get("links", {}).get("next") url = f"{base}{nxt}" if nxt else None for customer in list_all("/v1/customers"): print(customer["id"], customer["attributes"]["name"]) ``` ```bash title="kusty" # --all follows links.next and streams one JSON object per line kusty api GET /v1/customers --all --fields id,attributes.name ``` `links.next` is a path. Prefix it with your base URL. ## Cursor pagination A few endpoints, such as the [audit log](https://developer.kustomer.com/api/core-resources/query-events-associated-with-your-organization), page with `before` and `after` cursors instead. Their `links.next` already contains the cursor: ```json { "links": { "next": "/v1/audit-logs?count=1&before=62799c44a96e7f160834199c" } } ``` The same "follow `links.next`" loop works without changes. ## Large result sets Deep pagination gets slower and can hit limits. For exports and syncs: - **Search returns only the first 10,000 matches.** A request where `page` × `pageSize` is over 10,000 fails with `400`, and `pageSize` goes up to 500. Split large queries into time windows on `updated_at` or `created_at`, and page through each window. See [Search recipes](https://developer.kustomer.com/docs/guides/search-recipes#export-more-than-the-page-limit). - **Sort for stability.** Records created or updated while you page can shift between pages. Window by time, or sort by a stable field where the endpoint supports it. - **Incremental sync.** After the first full pass, query only records updated since your last run instead of re-listing everything. - **Subscribe instead of polling.** If you poll for changes, consider [outbound webhooks](https://developer.kustomer.com/docs/events/outbound-webhooks). --- # Search > Query customers, conversations, messages, companies, notes and custom objects with Kustomer's search API, including filters, operators, dates, custom attributes, sorting, limits and every standard attribute. Source: https://developer.kustomer.com/docs/concepts/search The search API runs the same queries as saved searches in Kustomer. One endpoint covers every object type: you pick what to return with `queryContext`, and filter with `and`, `or` and `not` criteria on any standard or custom attribute. `POST /v1/customers/search` ([reference](https://developer.kustomer.com/api/core-resources/create-customer-search)) ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/customers/search?pageSize=100" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "queryContext": "conversation", "and": [ { "conversation_status": { "equals": "open" } }, { "conversation_priority": { "gte": 4 } }, { "conversation_created_at": { "gte": "2026-10-01" } } ], "timeZone": "America/New_York" }' ``` Despite the path, the endpoint isn't limited to customers. With `queryContext: "conversation"`, it returns conversations. ## Request body **Search body** Query parameters: `page`, `pageSize` (up to 500), and `idsOnly=true` to return only matching IDs, which is faster for large syncs. ## Criteria Each criterion maps one attribute to one operator and value: ```json { "": { "": } } ``` Attribute names are `_` in snake case, such as `conversation_status` or `customer_created_at`. You can filter on attributes of related objects too: a `customer` search can filter on `conversation_status` to find customers with open conversations. ### Operators Which operators an attribute accepts depends on its operator group, listed in the [standard attributes](#standard-attributes-json-schema) tables. | Operator group | Operators | Example | | -------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------- | | `string` | `equals`, `not_equals`, `contains`, `not_contains`, `is_set`, `not_set` | `{ "customer_name": { "contains": "doe" } }` | | `string_notanalyzed` | `equals`, `not_equals`, `is_set`, `not_set` (exact match) | `{ "customer_externalId": { "equals": "crm-10293" } }` | | `text` | `contains`, `not_contains`, `is_set`, `not_set` | `{ "message_body": { "contains": "refund" } }` | | `list` | `equals`, `not_equals`, `in`, `not_in`, `is_set`, `not_set` | `{ "conversation_status": { "in": ["open", "snoozed"] } }` | | `number` | `equals`, `between`, `gte`, `lte`, `is_set`, `not_set` | `{ "conversation_message_count": { "gte": 5 } }` | | `datetime` | `gt`, `gte`, `lt`, `lte`, `is_set`, `not_set` | `{ "conversation_created_at": { "gte": "2026-10-01" } }` | | `boolean` | `equals`, `is_set`, `not_set` | `{ "kobject_imported": { "equals": true } }` | | `any` | `matches` (free-text match across the object) | `{ "customer_any_text": { "matches": "order 4417" } }` | `in` and `not_in` take arrays. `is_set` and `not_set` ignore their value; send an empty string (company searches need `true`). `contains` is case-insensitive. ### Combining criteria ```json { "queryContext": "customer", "and": [{ "customer_custom_loyaltyTierStr": { "equals": "gold" } }], "or": [ { "conversation_status": { "equals": "open" } }, { "conversation_status": { "equals": "snoozed" } } ], "not": [{ "customer_email": { "contains": "@acme.example" } }] } ``` _An interactive query builder for a conversation search. The reader switches criteria on and off (conversation_status with equals or in, conversation_priority with gte, lte or equals, a conversation_created_at range built from a gte and an lte criterion, and customer_custom_loyaltyTierStr), moves each one between the and, or and not groups, and picks a customer attribute to sort by. The POST /v1/customers/search request body updates live with the changed lines highlighted, while twelve sample conversations show which records match, why each one passes or fails, and the sorted order. It shows that and requires every criterion, or needs at least one, not excludes matches, and sorting always uses customer attributes._ ## Dates Dates can be local (`YYYY-MM-DD`) or UTC timestamps: - A **local date** is interpreted in `timeZone`, or GMT if you omit it. `{"conversation_created_at": {"lte": "2026-10-19"}}` with `"timeZone": "America/New_York"` means "up to the end of 19 October, New York time." - A **UTC timestamp** such as `2026-10-20T16:34:00.000Z` is used exactly, with no time-zone adjustment. ## Custom attributes | Object | Pattern | Example | | ---------------- | ------------------------------------ | ---------------------------------------------------------------- | | Standard objects | `_custom_` | `{ "customer_custom_loyaltyTierStr": { "equals": "gold" } }` | | Custom objects | `kobject_custom__` | `{ "kobject_custom_ecomm-order_totalPriceNum": { "gte": 100 } }` | The attribute's suffix decides its operator group: `Str` behaves like `string`, `Num` like `number`, `At` like `datetime`, `Bool` like `boolean`. ## Sorting Sorting only works on customer attributes, whatever the `queryContext`: ```json { "sort": [{ "customer_updated_at": "asc" }] } ``` Sortable attributes: `customer_name`, `customer_gender`, `customer_created_at`, `customer_updated_at`, `customer_modified_at`, `customer_externalId`, `customer_activity_at`, `customer_last_seen_at`, `customer_signed_up_at`, `customer_lastmessage_preview`, `customer_progressive_status`, `customer_done`, `customer_open`, `customer_snoozed`, `customer_last_message_sent_at`, `customer_last_message_out_sent_at`, `customer_last_message_unresponded_to_sent_at`, `customer_last_message_at`. ## Response ```json title="200 OK" { "meta": { "pageSize": 100, "page": 1, "total": 238, "totalPages": 3 }, "links": { "self": "...", "next": "..." }, "data": [ { "type": "conversation", "id": "58228e05d4e96711004eccb7", "attributes": { "name": "Where is my order?", "status": "open" } } ] } ``` ## Limits - **10,000 results per query.** `pageSize` goes up to 500, and a request where `page × pageSize` is over 10,000 returns `400`. For bigger result sets, window the query by `updated_at` and run one search per window. [Search recipes](https://developer.kustomer.com/docs/guides/search-recipes#export-more-than-the-page-limit) has the code. - **Two years of data.** Search covers records updated in the past two years. For older standard and custom objects, use the archive search endpoint with the same body: `POST /v1/customers/archive/search` ([reference](https://developer.kustomer.com/api/core-resources/create-customer-archive-search)) - **Rate limit.** Machine users (API keys) can make 100 search requests per minute. See [Rate limits](https://developer.kustomer.com/docs/concepts/rate-limits). - **Index delay.** Search is updated asynchronously, so a record you just created can take a moment to appear. To read your own writes, fetch the record by ID or `externalId`. ## Standard attributes JSON schema Every standard attribute you can filter on, grouped by object. The operator group decides which [operators](#operators) apply. The full JSON schema is also in the [search endpoint's reference](https://developer.kustomer.com/api/core-resources/create-customer-search). ### Customer attributes | Attribute | Type | Operator group | Values | | ---------------------------------------------- | --------- | -------------------- | ----------------------------------------------------------------------- | | `customer_any_text` | string | `any` | | | `customer_name` | string | `string` | | | `customer_gender` | string | `list` | `m`, `f` | | `customer_presence` | string | `list` | `online`, `offline`, `idle` | | `customer_email` | string | `string` | | | `customer_shared_email` | string | `string` | | | `customer_phone` | string | `string` | | | `customer_shared_phone` | string | `string` | | | `customer_urls` | string | `string` | | | `customer_address` | string | `string` | | | `customer_locale` | string | `string` | | | `customer_activity_at` | date-time | `datetime` | | | `customer_last_seen_at` | date-time | `datetime` | | | `customer_signed_up_at` | date-time | `datetime` | | | `customer_last_message_sent_at` | date-time | `datetime` | | | `customer_last_message_out_sent_at` | date-time | `datetime` | | | `customer_last_message_unresponded_to_sent_at` | date-time | `datetime` | | | `customer_last_message_at` | date-time | `datetime` | | | `customer_externalId` | string | `string_notanalyzed` | | | `customer_shared_externalId` | string | `string_notanalyzed` | | | `customer_created_at` | date-time | `datetime` | | | `customer_updated_at` | date-time | `datetime` | | | `customer_modified_at` | date-time | `datetime` | | | `customer_created_by` | user | `list` | | | `customer_modified_by` | user | `list` | | | `customer_satisfaction_avg_score` | number | `number` | | | `customer_satisfaction_avg_rating` | number | `number` | | | `customer_conversations_count` | number | `number` | | | `customer_status` | string | `list` | `snoozed`, `open`, `done` | | `customer_progressive_status` | string | `list` | `snoozed`, `open`, `done` | | `customer_active_users` | user | `list` | | | `customer_watchers` | user | `list` | | | `customer_tags` | string | `list` | | | `customer_sentiment` | string | `list` | `happy`, `unhappy`, `indifferent` | | `customer_social_type` | string | `list` | `facebook`, `instagram`, `whatsapp`, `linkedin`, `pinterest`, `twitter` | | `customer_social_userid` | string | `string_notanalyzed` | | | `customer_social_username` | string | `string_notanalyzed` | | | `customer_shared_social_type` | string | `list` | `facebook`, `instagram`, `whatsapp`, `linkedin`, `pinterest`, `twitter` | | `customer_shared_social_userid` | string | `string_notanalyzed` | | | `customer_shared_social_username` | string | `string_notanalyzed` | | | `customer_default_lang` | string | `string_notanalyzed` | | | `customer_imported_at` | date-time | `datetime` | | | `customer_imported` | boolean | `boolean` | | ### Company attributes | Attribute | Type | Operator group | Values | | ---------------------- | --------- | -------------------- | ------ | | `company_any_text` | string | `any` | | | `company_name` | string | `string` | | | `company_tags` | string | `list` | | | `company_default_lang` | string | `string_notanalyzed` | | | `company_imported_at` | date-time | `datetime` | | | `company_imported` | boolean | `boolean` | | ### Message attributes | Attribute | Type | Operator group | Values | | -------------------------- | --------- | -------------------- | --------------------------------------------------------------------------------------------------- | | `message_any_text` | string | `any` | | | `message_body` | string | `text` | | | `message_channel` | string | `list` | `sms`, `chat`, `email`, `facebook`, `voice`, `twitter-dm`, `twitter-tweet`, `instagram`, `whatsapp` | | `message_size` | number | `number` | | | `message_direction` | string | `boolean` | `in`, `out` | | `message_sent_at` | date-time | `datetime` | | | `message_created_at` | date-time | `datetime` | | | `message_updated_at` | date-time | `datetime` | | | `message_created_by` | user | `list` | | | `message_modified_by` | user | `list` | | | `message_sentiment` | string | `list` | `happy`, `unhappy`, `indifferent` | | `message_customer_id` | string | `string_notanalyzed` | | | `message_status` | string | `list` | `sent`, `received`, `error` | | `message_error_at` | date-time | `datetime` | | | `message_error_title` | string | `string` | | | `message_external_id` | string | `string_notanalyzed` | | | `message_direction_type` | string | `list` | `initial-in`, `initial-out`, `response-in`, `response-out`, `followup-in`, `followup-out` | | `message_created_by_teams` | string | `string_notanalyzed` | | | `message_redacted` | string | `boolean` | | | `message_redacted_at` | date-time | `datetime` | | | `message_imported_at` | date-time | `datetime` | | | `message_imported` | boolean | `boolean` | | | `message_recipients` | string | `string` | | | `message_sender` | string | `string` | | | `message_shortcuts` | string | `string_notanalyzed` | | | `message_subject` | string | `text` | | | `message_source` | string | `list` | `bulk` | | `message_attachment_count` | number | `number` | | | `message_auto` | string | `boolean` | | | `message_lang` | string | `string_notanalyzed` | | ### Note attributes | Attribute | Type | Operator group | Values | | ----------------------- | --------- | -------------------- | ------ | | `note_any_text` | string | `any` | | | `note_body` | string | `text` | | | `note_created_at` | date-time | `datetime` | | | `note_modified_at` | date-time | `datetime` | | | `note_updated_at` | date-time | `datetime` | | | `note_created_by` | user | `list` | | | `note_modified_by` | user | `list` | | | `note_deleted` | user | `boolean` | | | `note_user_mentions` | user | `list` | | | `note_team_mentions` | string | `list` | | | `note_attachment_count` | user | `number` | | | `note_external_id` | string | `string_notanalyzed` | | | `note_customer_id` | string | `string_notanalyzed` | | | `note_conversation_id` | string | `string_notanalyzed` | | | `note_lang` | string | `string_notanalyzed` | | | `note_imported_at` | date-time | `datetime` | | | `note_imported` | boolean | `boolean` | | ### Conversation attributes | Attribute | Type | Operator group | Values | | -------------------------------------------------- | --------- | -------------------- | --------------------------------------------------------------------------------------------------------------- | | `conversation_any_text` | string | `any` | | | `conversation_name` | string | `string` | | | `conversation_sentiment` | string | `list` | `happy`, `unhappy`, `indifferent` | | `conversation_status` | string | `list` | `snoozed`, `open`, `done` | | `conversation_sub_status` | string | `string_notanalyzed` | | | `conversation_created_at` | date-time | `datetime` | | | `conversation_updated_at` | date-time | `datetime` | | | `conversation_modified_at` | date-time | `datetime` | | | `conversation_modified_by` | user | `list` | | | `conversation_message_count` | number | `number` | | | `conversation_note_count` | number | `number` | | | `conversation_satisfaction_survey_id` | string | `list` | | | `conversation_satisfaction_sent_by` | user | `list` | | | `conversation_satisfaction_sent_by_teams` | string | `list` | | | `conversation_satisfaction_created_at` | date-time | `datetime` | | | `conversation_satisfaction_score` | number | `number` | | | `conversation_satisfaction_rating` | number | `number` | | | `conversation_satisfaction_status` | string | `list` | `canceled`, `scheduled`, `offered`, `unresponded`, `rated`, `commented` | | `conversation_channels` | string | `list` | `sms`, `chat`, `email`, `facebook`, `voice`, `twitter-dm`, `twitter-tweet`, `instagram`, `whatsapp` | | `conversation_satisfaction` | string | `string` | | | `conversation_priority` | integer | `number` | | | `conversation_tags` | string | `list` | | | `conversation_assigned_users` | user | `list` | | | `conversation_assigned_teams` | string | `list` | | | `conversation_last_message_sent_at` | date-time | `datetime` | | | `conversation_last_message_out_sent_at` | date-time | `datetime` | | | `conversation_last_message_unresponded_to_sent_at` | date-time | `datetime` | | | `conversation_last_message_at` | date-time | `datetime` | | | `conversation_first_response_sent_at` | date-time | `datetime` | | | `conversation_first_response_time` | number | `number` | | | `conversation_first_response_created_by` | user | `list` | | | `conversation_last_response_created_by` | user | `list` | | | `conversation_last_message_direction` | string | `list` | `in`, `out` | | `conversation_direction` | string | `list` | `in`, `out` | | `conversation_outbound_message_count` | number | `number` | | | `conversation_customer_id` | string | `string_notanalyzed` | | | `conversation_campaign_id` | string | `string_notanalyzed` | | | `conversation_first_message_in_sent_at` | date-time | `datetime` | | | `conversation_first_message_in_direction_type` | string | `list` | `initial-in`, `initial-out`, `response-in`, `response-out`, `followup-in`, `followup-out` | | `conversation_first_message_in_channel` | string | `list` | `sms`, `chat`, `email`, `facebook`, `voice`, `twitter-dm`, `twitter-tweet`, `instagram`, `whatsapp` | | `conversation_first_message_out_sent_at` | date-time | `datetime` | | | `conversation_first_message_out_direction_type` | string | `list` | `initial-in`, `initial-out`, `response-in`, `response-out`, `followup-in`, `followup-out` | | `conversation_first_message_out_channel` | string | `list` | `sms`, `chat`, `email`, `facebook`, `voice`, `twitter-dm`, `twitter-tweet`, `instagram`, `whatsapp` | | `conversation_time_created_at` | date-time | `datetime` | | | `conversation_time_conversation_id` | string | `string_notanalyzed` | | | `conversation_time_created_by_teams` | string | `list` | | | `conversation_time_assigned_users` | user | `list` | | | `conversation_time_assigned_teams` | string | `list` | | | `conversation_time_touched` | string | `boolean` | | | `conversation_time_reopened` | string | `boolean` | | | `conversation_time_snoozed` | string | `boolean` | | | `conversation_time_resolved` | string | `boolean` | | | `conversation_time_message_at` | date-time | `datetime` | | | `conversation_time_message_time` | number | `number` | | | `conversation_time_messages_sent` | number | `number` | | | `conversation_time_note_at` | date-time | `datetime` | | | `conversation_time_note_time` | number | `number` | | | `conversation_time_notes_sent` | number | `number` | | | `conversation_time_handle_at` | date-time | `datetime` | | | `conversation_time_handle_time` | number | `number` | | | `conversation_time_channels` | string | `list` | `sms`, `chat`, `email`, `facebook`, `voice`, `twitter-dm`, `twitter-tweet`, `instagram`, `whatsapp` | | `conversation_default_lang` | string | `string_notanalyzed` | | | `conversation_sla_status` | string | `list` | `done`, `pending`, `paused` | | `conversation_sla_version_id` | string | `string_notanalyzed` | | | `conversation_sla_id` | string | `list` | | | `conversation_sla_breached` | boolean | `boolean` | | | `conversation_sla_breach_at` | date-time | `datetime` | | | `conversation_sla_satisfied_at` | date-time | `datetime` | | | `conversation_sla_breach_metric` | string | `list` | `firstResponse`, `nextReply`, `totalCustomerWaitTime`, `longestUnrespondedMessage`, `totalConversationOpenTime` | | `conversation_first_done_created_by` | user | `list` | | | `conversation_last_done_created_at` | date-time | `datetime` | | | `conversation_last_done_created_by` | user | `list` | | | `conversation_last_done_created_by_teams` | string | `list` | | | `conversation_reply_channel` | string | `list` | `sms`, `chat`, `email`, `facebook`, `voice`, `twitter-dm`, `twitter-tweet`, `instagram`, `whatsapp` | | `conversation_queue` | string | `list` | | | `conversation_external_queue` | string | `list` | `amazon-connect` | | `conversation_ended` | boolean | `boolean` | | | `conversation_ended_at` | date-time | `datetime` | | | `conversation_ended_reason` | string | `string` | | | `conversation_imported_at` | date-time | `datetime` | | | `conversation_imported` | boolean | `boolean` | | ### Other attributes | Attribute | Type | Operator group | Values | | ----------------- | ------ | -------------- | ------ | | `any_text` | string | `any` | | | `tracking_events` | string | `number` | | ### Kobject attributes | Attribute | Type | Operator group | Values | | --------------------- | --------- | -------------- | ------ | | `kobject_any_text` | string | `any` | | | `kobject_created_at` | date-time | `datetime` | | | `kobject_updated_at` | date-time | `datetime` | | | `kobject_imported_at` | date-time | `datetime` | | | `kobject_imported` | boolean | `boolean` | | --- # Rate limits > Kustomer's org-wide request limits by plan, search and webhook limits, per-object update limits, rate-limit headers, and how to stay under them. Source: https://developer.kustomer.com/docs/concepts/rate-limits Kustomer limits how many API requests an org can make per minute, and how often a single record can be changed. Limits protect your org's performance for agents. When you exceed one, the API returns `429 Too Many Requests` until the window resets. _Interactive simulation of the org-wide per-minute request budget. Choose a plan (Enterprise 1,000, Ultimate 2,000, or legacy Business 500 requests per minute) and a request rate. Requests drain the budget, 429 responses begin when it's empty, and it refills at the reset time shown in x-ratelimit-reset. A toggle shows a client that pauses until reset instead of retrying._ ## Org-wide API limit All API requests from your org count toward one shared budget, measured over 60 seconds. Every API key and every app user in the org draws from it, including bulk requests, paginated requests and requests made on behalf of agents. | Plan | Requests per minute | | --------------------- | ------------------- | | Enterprise | 1,000 | | Ultimate | 2,000 | | Business (legacy) | 500 | | Professional (legacy) | 300 | Workflows and business rules running inside Kustomer don't count against this limit. ## Other request limits | What | Limit | | --------------------------------------------------- | ------------------------------------------------------------ | | Search requests by machine users (API keys) | 100 per minute | | Search requests by people using Kustomer | Not limited | | Requests by people using Kustomer (human app users) | 1,000 per minute | | Inbound webhooks and form hooks | 1,000 per minute (Enterprise) or 2,000 per minute (Ultimate) | | Inbound webhook and form hook body size | 400 KB | | Outbound WhatsApp messages | 300 per minute per user | | Workflow executions | 1,000 per 30 seconds per event type | Kustomer's built-in integrations aren't subject to your org's limit. Inbound email has its own sender protection: 20 emails per hour from the same address. ## Object limits To stop runaway loops, Kustomer also limits how often one record can change, no matter how much of your budget is left: | What | Limit | | ----------------------------------------------------------------------------------------- | ------------------ | | Updates to one conversation, company or message | 100 per 10 minutes | | Updates to one customer through the API | 600 per 10 minutes | | Updates to one object by a single user | 50 per 10 minutes | | New drafts, messages, conversations, notes or KObjects for one customer, by a single user | 120 per minute | Records created with `importedAt` skip the per-customer create limit, so historical imports aren't throttled by it. They still count toward the org-wide limit. > **Most object-limit 429s are loops:** If one record hits an object limit, look for a feedback loop: a webhook handler that updates the conversation that triggered it, or two systems syncing the same field back and forth. Compare the incoming change against what you'd write, and skip the update when nothing differs. ## Headers Every response reports your org-wide budget: | Header | Meaning | | ----------------------- | -------------------------------------------- | | `x-ratelimit-limit` | Requests allowed in the current window | | `x-ratelimit-remaining` | Requests left in the current window | | `x-ratelimit-reset` | When the window resets, in UTC epoch seconds | ```http HTTP/1.1 429 Too Many Requests x-ratelimit-limit: 1000 x-ratelimit-remaining: 0 x-ratelimit-reset: 1761058860 ``` ## Handling 429s #### Wait for the reset On a `429`, sleep until `x-ratelimit-reset`, then retry. Add a little random jitter so parallel workers don't all retry at the same instant. #### Slow down before you hit the limit Watch `x-ratelimit-remaining`. When it falls below about 10% of the limit, space out requests until the reset. #### Share the budget If several services use the same org, give each a share (for example, 40% for the nightly sync and 20% for webhooks), and run them through a shared limiter or queue. ```ts title="Node.js" class KustomerLimiter { private resetAt = 0 private remaining = Infinity async request(url: string, init: RequestInit): Promise { if (this.remaining < 25 && Date.now() < this.resetAt) await sleep(this.resetAt - Date.now()) const res = await fetch(url, init) this.remaining = Number(res.headers.get('x-ratelimit-remaining') ?? Infinity) this.resetAt = Number(res.headers.get('x-ratelimit-reset') ?? 0) * 1000 if (res.status === 429) { await sleep(Math.max(this.resetAt - Date.now(), 1000) + Math.random() * 500) return this.request(url, init) } return res } } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) ``` ## Using fewer requests - **Bulk endpoints.** Create or update up to 100 records in one job. See [Bulk operations](https://developer.kustomer.com/docs/guides/bulk-operations). - **Bigger pages.** Use the largest `pageSize` the endpoint allows. - **`include`.** Side-load related records instead of fetching them one by one. - **Webhooks over polling.** Let Kustomer tell you what changed. See [Outbound webhooks](https://developer.kustomer.com/docs/events/outbound-webhooks). - **Skip no-op writes.** Compare before you update. It saves budget and avoids object limits. - **`idsOnly` searches.** Return just the IDs when that's all you need. --- # Errors > HTTP status codes, error codes and response shapes returned by the Kustomer API, what causes each, and which to retry. Source: https://developer.kustomer.com/docs/concepts/errors The Kustomer API uses standard HTTP status codes. `2xx` means success, `4xx` means something about the request needs to change, and `5xx` means something went wrong on Kustomer's side. ## Error shape Most errors return an `errors` array. Each entry has a machine-readable `code`, a human-readable `message`, and optional `meta`: ```json title="400 Bad Request" { "errors": [ { "code": "bad_request", "message": "Invalid request body or parameters.", "meta": {} } ] } ``` Some endpoints return the JSON:API error object instead, with `status`, `code`, `title`, `detail`, `source` (pointing at the bad field) and `meta`. Write your error handling to accept both: ```ts title="Node.js" interface KustomerError { code?: string message?: string title?: string detail?: string status?: string source?: { pointer?: string; parameter?: string } } async function errorMessage(res: Response): Promise { const body = await res.json().catch(() => null) const err: KustomerError | undefined = body?.errors?.[0] if (!err) return `HTTP ${res.status}` const where = err.source?.pointer ?? err.source?.parameter return [err.code, err.message ?? err.detail ?? err.title, where && `at ${where}`].filter(Boolean).join(': ') } ``` ## Status codes | Status | Code | Common causes | Retry? | | ------ | --------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `400` | `bad_request` | Invalid JSON, missing required fields, wrong types, unknown enum values, bad custom attribute suffix | No: fix the request | | `401` | `unauthorized` | Missing `Authorization` header, missing `Bearer ` prefix, expired or deleted key, request from outside the key's IP range | No: fix the credentials | | `403` | `forbidden` | The key is valid but lacks a role the operation needs | No: use a key with the right role | | `404` | `not_found` | Wrong ID, a lookup such as `externalId=` found nothing, or the record was deleted | No. For lookups, `404` is a normal "not found" answer. | | `409` | `conflict` | Duplicate unique value, or a concurrent write to the same resource, such as a company hierarchy edge | Sometimes: retry if the error says another write was in progress | | `414` | `uri_too_long` | Too many IDs in a query string | No: batch the IDs | | `422` | `unprocessable` | The request is valid but breaks a business rule | No | | `429` | none | Over the org-wide or an object [rate limit](https://developer.kustomer.com/docs/concepts/rate-limits) | Yes, after `x-ratelimit-reset` | | `5xx` | none | A temporary problem on Kustomer's side | Yes, with exponential backoff | > **Region mismatch:** If you see `Auth token associated with pod prod2 but request is being handled by prod1`, the token belongs to an org in another region. Use your org host, `https://.api.kustomerapp.com`. See [Regions and base URLs](https://developer.kustomer.com/docs/get-started/regions-and-base-urls). ## Retry policy ```mermaid flowchart TD R[Response] --> S{Status} S -- "2xx" --> OK[Done] S -- "429" --> W["Wait until x-ratelimit-reset
+ jitter, then retry"] S -- "5xx or network error" --> B["Exponential backoff
0.5s, 1s, 2s, 4s… (max 5 tries)"] S -- "409 'in progress'" --> B S -- "other 4xx" --> F[Fail: log code and message, fix the request] ``` Retrying a `POST` that timed out can create duplicates. Make creates safe to repeat by setting `externalId` and looking it up before you retry. See [Making requests](https://developer.kustomer.com/docs/get-started/making-requests#idempotency-and-retries). ## Bulk job errors Bulk jobs report errors per item instead of failing the whole request. When you send `_clientRef`, each result has a `status` of `created`, `updated` or `error`, and failed items include an `error` reason. See [Bulk operations](https://developer.kustomer.com/docs/guides/bulk-operations#3-read-the-results). ## Debugging checklist 1. Log the status, the `errors` array, the method and path. Never log the `Authorization` header. 2. Reproduce the request with the **Try it** panel on the operation's reference page, or with `kusty api ... --dry-run` to see exactly what would be sent. 3. Check the roles on the reference page against your key's roles from `GET /v1/auth/tokens/current`. 4. For `400` errors, compare your body with the request schema on the reference page. Custom attribute names must end in a valid suffix. --- # Versioning and deprecation > How the Kustomer API is versioned, how deprecations are marked, and how to build clients that keep working as the API grows. Source: https://developer.kustomer.com/docs/concepts/versioning The Kustomer API is versioned in the path. Nearly every endpoint is under `/v1`, and it evolves in place by adding to it. A new major version of an endpoint gets a new path, and the old one keeps working alongside it. ## Path versions | Version | Status | Example | | ------- | ------------------------------------------------------------- | ---------------------------------- | | `/v1` | Current. Almost every endpoint. | `GET /v1/customers/{id}` | | `/v2` | Available for specific endpoints whose response changed shape | `GET /v2/conversations/{id}/times` | The `/v2` endpoints are listed in the [Core resources v2](https://developer.kustomer.com/api/core-resources-v2) reference. When a `/v2` endpoint exists, prefer it for new code. ## Changes you should expect The API grows without a version bump. Build your client to tolerate these: - **New endpoints and new optional request fields.** - **New attributes in responses.** Ignore fields you don't recognize instead of failing validation. - **New enum values**, such as a new message `channel` or event type. Handle unknown values gracefully, for example by logging and skipping. - **New event types** on webhooks. Subscribe to the specific events you need, and ignore others. - **New error `code` values.** Fall back to the HTTP status. ```ts title="Node.js" // Tolerant parsing: pick what you need, ignore the rest. const { id, attributes } = body.data const conversation = { id, name: attributes.name ?? '', status: ['open', 'snoozed', 'done'].includes(attributes.status) ? attributes.status : 'unknown', } ``` ## Deprecation Deprecated operations are labeled **Deprecated** in the [API reference](https://developer.kustomer.com/api). They keep working, but new integrations shouldn't use them. Check the operation's description for the recommended alternative. Changes and deprecations are announced in the [changelog](https://developer.kustomer.com/changelog), which you can also read as [Markdown](https://developer.kustomer.com/changelog.md). ## Other versioned surfaces | Surface | How it's versioned | | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | [Chat SDKs](https://developer.kustomer.com/docs/chat) | Semantic versions per platform, distributed through npm, Swift Package Manager and Maven. | | [Apps](https://developer.kustomer.com/docs/apps/versioning) | Each app definition has a `version`. Orgs upgrade installed apps to new versions. | | [Webhook payloads](https://developer.kustomer.com/docs/events/outbound-webhooks) | Follow the API's resource shapes, so the same tolerance rules apply. | --- # Guides > End-to-end recipes for the integrations teams build most on Kustomer, from CRM syncs and custom channels to order data, bulk imports, attachments and search. Source: https://developer.kustomer.com/docs/guides Each guide builds one complete integration, with working code, the endpoints it uses and the production details that usually get missed: idempotency, rate limits and loop prevention. They assume you've done the [quickstart](https://developer.kustomer.com/docs/get-started/quickstart). ## Integrations - [Sync customers from a CRM](https://developer.kustomer.com/docs/guides/sync-customers-from-crm): A two-way sync with a bulk backfill, upserts by `externalId`, companies, webhooks back to the CRM, and loop prevention. - [Bring messages from a custom channel](https://developer.kustomer.com/docs/guides/custom-channel-messages): Thread messages from your own inbox or a partner platform into conversations, and deliver agent replies back. - [Show orders with custom objects](https://developer.kustomer.com/docs/guides/kobject-orders-integration): Put orders on the timeline with a Klass and KObjects, link them to conversations, and show details in a KView. - [Model company hierarchies](https://developer.kustomer.com/docs/guides/company-hierarchy): Link parent companies and subsidiaries into trees, read breadcrumbs, and sync account structure from your CRM. ## Building blocks - [Bulk operations](https://developer.kustomer.com/docs/guides/bulk-operations): Create and update 100 records per request, poll for completion and match results with `_clientRef`. - [Upload attachments](https://developer.kustomer.com/docs/guides/attachments): Presigned uploads, files in your own storage, and downloads. - [Search recipes](https://developer.kustomer.com/docs/guides/search-recipes): Copy-paste queries, plus exports beyond the 100-page limit and incremental sync. ## More in other sections | If you want to | Read | | ---------------------------------------------- | --------------------------------------------------------------------------------- | | React to changes in Kustomer | [Outbound webhooks](https://developer.kustomer.com/docs/events/outbound-webhooks) | | Automate inside Kustomer | [Workflows](https://developer.kustomer.com/docs/workflows) | | Package an integration for many orgs | [Apps platform](https://developer.kustomer.com/docs/apps) | | Put chat in your app | [Chat SDKs](https://developer.kustomer.com/docs/chat) | | Script Kustomer from a terminal or an AI agent | [kusty](https://developer.kustomer.com/docs/ai/kusty) | > **Building with an AI assistant?:** Each guide is available as Markdown: add `.md` to the URL, or use **Copy page** at the top. Your assistant gets the code, endpoints and caveats in one paste. --- # Sync customers from a CRM > Build a production two-way sync between a CRM and Kustomer, with an initial bulk backfill, incremental upserts by externalId, companies, webhooks back to the CRM, and loop prevention. Source: https://developer.kustomer.com/docs/guides/sync-customers-from-crm This guide builds a customer sync between a CRM (Salesforce, HubSpot, an in-house system) and Kustomer. By the end you'll have: - a one-time **backfill** of every CRM contact and account; - **incremental upserts** whenever a contact changes in the CRM; - **changes flowing back** to the CRM when agents edit a customer in Kustomer; - protection against **update loops** between the two systems. ```mermaid flowchart LR subgraph CRM C[(Contacts
Accounts)] CW[CRM change
webhook] end subgraph Sync["Your sync service"] B[Backfill job] U[Upsert worker] H[Kustomer webhook
handler] end subgraph K[Kustomer] API[REST API] OW[Outbound webhook
kustomer.customer.update] end C --> B -- "bulk, 100 per job" --> API CW --> U -- "upsert by externalId" --> API API --> OW --> H -- "write back changed fields" --> C ``` **You'll need** an API key with `org.permission.customer.read`, `.create` and `.update`, the same for `company`, and `org.permission.outbound_webhook.create` (or the legacy `org.admin.outbound_webhooks`) if you create the webhook through the API. ## 1. Decide the mapping Map each CRM field to a Kustomer standard attribute or a [custom attribute](https://developer.kustomer.com/docs/concepts/custom-attributes). Keep the CRM's ID in `externalId`; it's what makes every later step idempotent. | CRM field | Kustomer attribute | Notes | | --------------- | -------------------------- | --------------------------------------------------- | | Contact ID | `externalId` | The join key. Never changes. | | Full name | `name` | | | Email | `emails[]` | `{ "type": "work", "email": "..." }` | | Phone | `phones[]` | E.164, such as `+15555550123` | | Account | `company` | Kustomer company ID (see step 3) | | Lifecycle stage | `custom.lifecycleStageStr` | Give it an `enumeration` in metadata for a dropdown | | Annual revenue | `custom.annualRevenueNum` | | | Owner email | `custom.accountOwnerStr` | | | Last synced | `custom.crmSyncedAt` | Helps you debug | Create the custom attribute metadata once, so agents see friendly labels: ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/metadata/customer" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "lifecycleStageStr": { "displayName": "Lifecycle stage", "enumeration": ["lead", "customer", "churned"] }, "annualRevenueNum": { "displayName": "Annual revenue" }, "accountOwnerStr": { "displayName": "Account owner" }, "crmSyncedAt": { "displayName": "Last CRM sync" } } }' ``` ## 2. Backfill with bulk jobs For the first load, send contacts in [bulk jobs](https://developer.kustomer.com/docs/guides/bulk-operations) of 100, with `_clientRef` and `externalId` set to the CRM ID: ```ts title="Node.js" for (const chunk of chunks(contacts, 100)) { const body = chunk.map((c) => ({ _clientRef: c.id, externalId: c.id, name: c.fullName, emails: c.email ? [{ type: 'work', email: c.email }] : [], phones: c.phone ? [{ type: 'work', phone: c.phone }] : [], custom: { lifecycleStageStr: c.stage, crmSyncedAt: new Date().toISOString() }, })) const job = await kfetch('/v1/customers/bulk', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.json()) await waitForBulk(job.data.id) // poll GET /v1/bulk/{id}; see Bulk operations } ``` > **Customers who already exist:** If people have already emailed your support team, Kustomer has customers for them, matched on email. Before the backfill, look up each contact by `externalId` and then by email, and update matches rather than creating duplicates. Or run the backfill as creates and merge duplicates afterwards with the [customer merge](https://developer.kustomer.com/api/core-resources/create-customer-merge) endpoint. ## 3. Companies Give each CRM account a company, with the account's ID in `externalId`. Look the company up by that ID first. The response is a list, and an empty `data` means the company doesn't exist yet: `GET /v1/companies?externalId={externalId}` ([reference](https://developer.kustomer.com/api/core-resources/get-companies)) Don't add `filter` to the same request; sending both makes the API ignore both. If there's no match, create the company: `POST /v1/companies` ([reference](https://developer.kustomer.com/api/core-resources/create-company)) ```json title="Request body" { "name": "Acme Corp", "externalId": "acct-0042", "domains": [{ "domain": "acme.example" }], "custom": { "tierStr": "enterprise" } } ``` ```ts title="Node.js" async function companyIdFor(accountId: string): Promise { const res = await kfetch(`/v1/companies?externalId=${encodeURIComponent(accountId)}`) const { data } = await res.json() if (data.length > 0) return data[0].id const account = await crm.getAccount(accountId) const created = await kfetch('/v1/companies', { method: 'POST', body: JSON.stringify({ name: account.name, externalId: accountId }), }) return (await created.json()).data.id } ``` Cache the mapping in your sync service so a busy account doesn't cost a lookup on every contact change. Then link customers by setting `company` to the Kustomer company ID. Remember that **omitting `company` on a customer update unlinks it**, so always send it when you update a customer that has an account. ## 4. Incremental upserts When the CRM reports a change, upsert the one customer: ```ts title="Node.js" async function upsertContact(c: CrmContact) { const attrs = { externalId: c.id, name: c.fullName, emails: c.email ? [{ type: 'work', email: c.email }] : [], company: c.accountId ? await companyIdFor(c.accountId) : undefined, custom: { lifecycleStageStr: c.stage, crmSyncedAt: new Date().toISOString() }, } const found = await kfetch(`/v1/customers/externalId=${encodeURIComponent(c.id)}`) if (found.status === 404) { return kfetch('/v1/customers', { method: 'POST', body: JSON.stringify(attrs) }) } const { data } = await found.json() if (!changed(data.attributes, attrs)) return // skip no-op writes return kfetch(`/v1/customers/${data.id}`, { method: 'PUT', body: JSON.stringify(attrs) }) } ``` The `changed()` check matters: it saves rate limit, and it's what keeps the next step from looping. ## 5. Send Kustomer changes back Subscribe to customer updates with an [outbound webhook](https://developer.kustomer.com/docs/events/outbound-webhooks): `POST /v1/outbound-webhooks` ([reference](https://developer.kustomer.com/api/apps-platform/create-outbound-webhook)) ```json title="Request body" { "name": "CRM sync", "url": "https://sync.acme.example/kustomer/webhooks", "events": ["kustomer.customer.update"] } ``` Each delivery includes the customer and a `changes` object with the before and after value of every attribute that changed. Write back only the fields you own in Kustomer: ```ts title="Node.js" const OWNED_IN_KUSTOMER = new Set(['phones', 'custom']) app.post('/kustomer/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req)) return res.sendStatus(401) // see Delivery and security const event = JSON.parse(req.body.toString()) res.sendStatus(200) // acknowledge fast, then work const changed = Object.keys(event.changes?.attributes ?? {}).filter((k) => OWNED_IN_KUSTOMER.has(k)) if (event.client === 'api' && (await wasOurWrite(event.data.id, event.changes))) return // see "Avoid loops" if (!changed.length) return await crm.updateContact(event.data.attributes.externalId, pick(event.data.attributes, changed)) }) ``` [Delivery and security](https://developer.kustomer.com/docs/events/delivery-and-retries) shows how to verify `X-Kustomer-Webhook-Signature`. ## 6. Avoid loops A two-way sync can ping-pong: the CRM update triggers a Kustomer update, which fires a webhook, which updates the CRM, and so on. Kustomer's [object limits](https://developer.kustomer.com/docs/concepts/rate-limits#object-limits) will eventually stop it with `429`s, but you should never get there. - **Own each field in one system.** The CRM owns name, email and account; Kustomer owns phone and support-specific fields. Each side only writes the fields it owns. - **Skip no-op writes.** Compare before you update, on both sides. - **Ignore your own echoes.** Webhook events include a `client` field showing where the change came from, and `changes` lists exactly what changed. If the only changes are fields your sync just wrote, drop the event. ## 7. Run it in production #### Respect rate limits Run all sync traffic through one limiter, and leave headroom for other integrations. See [Rate limits](https://developer.kustomer.com/docs/concepts/rate-limits). #### Reconcile nightly Webhooks can be missed during outages. A nightly job that searches for customers with `customer_updated_at` in the last day and compares them with the CRM catches anything that slipped through. See [Search recipes](https://developer.kustomer.com/docs/guides/search-recipes). #### Log by externalId Log the CRM ID, Kustomer ID, action and status for each write. It makes "why didn't this contact sync?" a one-line query. --- # Bring messages from a custom channel > Record conversations from your own messaging channel (an in-app inbox, a marketplace, a partner platform) on Kustomer timelines, thread them correctly, and deliver agent replies back. Source: https://developer.kustomer.com/docs/guides/custom-channel-messages Kustomer has native email, chat, SMS, voice and social channels. If your customers also talk to you somewhere else, such as an in-app inbox, a marketplace's messaging system or a partner platform, you can bring those conversations into Kustomer so agents see everything on one timeline. This guide covers the inbound path (customer messages into Kustomer), threading, and the outbound path (agent replies back to your channel). ```mermaid sequenceDiagram autonumber participant Ch as Your channel participant S as Your integration participant K as Kustomer API participant A as Agent Ch->>S: New customer message S->>K: Find or create customer (externalId) S->>K: Find conversation for thread (externalId) or create one S->>K: POST message (direction: in) A->>K: Agent replies in Kustomer K-->>S: Webhook kustomer.message.create (direction: out) S->>Ch: Deliver reply ``` ## How a custom channel maps to Kustomer | Your channel | Kustomer | Key field | | ---------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | | User account | Customer | `externalId` = your user ID | | Thread | Conversation | `externalId` = your thread ID | | Message | Message | `externalId` = your message ID | | Your integration | The message's `app` | A stable identifier, such as `acme-inbox` | | Message type | `channel` | The closest native channel: `chat` for real-time messaging, `sms` for text-like, `email` for long-form | Messages created through the API are records: Kustomer doesn't deliver them anywhere. Your integration is responsible for delivering agent replies to your channel. ## 1. Find or create the customer ```ts title="Node.js" async function customerFor(user: { id: string; name: string; email?: string }) { const found = await kfetch(`/v1/customers/externalId=${encodeURIComponent(user.id)}`) if (found.ok) return (await found.json()).data.id const created = await kfetch('/v1/customers', { method: 'POST', body: JSON.stringify({ externalId: user.id, name: user.name, emails: user.email ? [{ type: 'home', email: user.email }] : [] }), }).then((r) => r.json()) return created.data.id } ``` ## 2. Find or create the conversation Use your thread ID as the conversation's `externalId`, so every message in the thread lands in the same conversation: `GET /v1/conversations/externalId={externalId}` ([reference](https://developer.kustomer.com/api/core-resources/get-conversation-by-external-id)) `POST /v1/customers/{id}/conversations` ([reference](https://developer.kustomer.com/api/core-resources/create-conversation-for-customer)) ```ts title="Node.js" async function conversationFor(customerId: string, thread: { id: string; subject: string }) { const found = await kfetch(`/v1/conversations/externalId=${encodeURIComponent(thread.id)}`) if (found.ok) return (await found.json()).data.id const created = await kfetch(`/v1/customers/${customerId}/conversations`, { method: 'POST', body: JSON.stringify({ externalId: thread.id, name: thread.subject, direction: 'in', tags: ['acme-inbox'] }), }).then((r) => r.json()) return created.data.id } ``` Tag or set a custom attribute on these conversations (for example `custom.sourceChannelStr: "acme-inbox"`) so routing rules, reports and your webhook handler can recognize them. ## 3. Create the inbound message `POST /v1/conversations/{id}/messages` ([reference](https://developer.kustomer.com/api/core-resources/create-message-from-conversation)) ```json title="Request body" { "channel": "chat", "app": "acme-inbox", "direction": "in", "preview": "Hi, the seller hasn't shipped my order yet. Can you help?", "externalId": "msg_01HF3Q8Y2V", "sentAt": "2026-10-21T14:02:11Z", "meta": { "threadUrl": "https://inbox.acme.example/t/88123" } } ``` - `preview` holds the message text, up to 10,240 characters. - `externalId` lets you skip messages you've already imported: check [get message by external ID](https://developer.kustomer.com/api/core-resources/get-a-message-by-external-id) before creating. - `meta` is free-form. Put channel-specific details there. - Attachments: upload them first, then pass them in `attachments`. See [Upload attachments](https://developer.kustomer.com/docs/guides/attachments). When a new inbound message arrives on a conversation that's `done`, Kustomer's normal reopen and routing rules apply. ## 4. Deliver agent replies Subscribe an [outbound webhook](https://developer.kustomer.com/docs/events/outbound-webhooks) to `kustomer.message.create`, then deliver outbound messages that belong to your channel: ```ts title="Node.js" app.post('/kustomer/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req)) return res.sendStatus(401) const event = JSON.parse(req.body.toString()) res.sendStatus(200) if (event.name !== 'kustomer.message.create') return const msg = event.data if (msg.attributes.direction !== 'out' || msg.attributes.app === 'acme-inbox') return // ignore inbound and our own records const conversation = await kfetch(`/v1/conversations/${msg.relationships.conversation.data.id}`).then((r) => r.json()) if (!conversation.data.attributes.tags?.includes('acme-inbox')) return // not our channel await acmeInbox.send(conversation.data.attributes.externalId, msg.attributes.preview) }) ``` > **Test how agents reply in your org:** Which reply channel agents get on these conversations depends on your org's channel setup. Test the full loop in a sandbox org: create an inbound message, reply as an agent, and confirm the `kustomer.message.create` event your handler receives. Adjust the filter in your handler to match. ## 5. Import history To backfill old threads, use [bulk message creation](https://developer.kustomer.com/api/core-resources/bulk-create-messages) and set `importedAt` on each message. Imported records skip the per-customer create limit (120 per minute), and `sentAt` keeps the original timeline order. Create customers and conversations first, then their messages. ## Checklist - [ ] Customer, conversation and message all have `externalId` set. - [ ] Every message uses the same `app` value. - [ ] Inbound handler is idempotent (it checks `externalId` before creating). - [ ] Webhook handler verifies signatures, acknowledges within a few seconds and ignores its own records. - [ ] Historical imports set `importedAt`. --- # Show orders with custom objects > Build an e-commerce orders integration with a Klass, KObjects synced from your store, a link from conversations to orders, and a KView that shows order details to agents. Source: https://developer.kustomer.com/docs/guides/kobject-orders-integration "Where is my order?" is the most common question in e-commerce support. This guide puts orders on the customer timeline so agents can answer without leaving Kustomer, and so workflows can route and automate based on order data. You'll build: 1. An `ecomm-order` **Klass** with typed attributes. 2. A sync that creates and updates **KObjects** from your store's order events. 3. A **conversation attribute** that links a conversation to the order it's about. 4. A **KView** that shows line items and tracking to agents. ```mermaid flowchart LR Store[(Your store)] -- "order.created
order.updated" --> Sync[Order sync] Sync -- "upsert by externalId" --> KO[KObject
ecomm-order] KO --> Cust[Customer timeline] Conv[Conversation
custom.orderId] -. references .-> KO KV[KView] -. renders .-> KO ``` ## 1. Define the Klass Create the Klass and its attribute metadata. If you're packaging this as an app, put the same definition in your [app definition](https://developer.kustomer.com/docs/apps/klasses) instead, so it installs everywhere. ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/klasses" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "ecomm-order", "displayName": "Order", "icon": "basket", "color": "#64943E" }' curl -X PUT "https://acme.api.kustomerapp.com/v1/metadata/kobject.ecomm-order" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "orderNumberStr": { "displayName": "Order number" }, "orderStatusStr": { "displayName": "Status", "enumeration": ["placed", "paid", "shipped", "delivered", "cancelled", "returned"] }, "totalPriceNum": { "displayName": "Total" }, "currencyStr": { "displayName": "Currency" }, "orderedAt": { "displayName": "Ordered" }, "shippedAt": { "displayName": "Shipped" }, "carrierStr": { "displayName": "Carrier" }, "trackingUrl": { "displayName": "Tracking link" }, "adminUrl": { "displayName": "Open in store admin" } } }' ``` Attribute names end in a type suffix: `Str`, `Num`, `At`, `Url`. Put anything you'll filter, search or automate on in `custom`. Put nested data such as line items in `data`. ## 2. Sync orders Subscribe to your store's order events and upsert one KObject per order, keyed by `externalId`: ```ts title="Node.js" const KLASS = 'ecomm-order' async function upsertOrder(order: StoreOrder) { const customerId = await customerFor(order.customer) // find or create by externalId or email const body = JSON.stringify({ title: `Order #${order.number}`, description: `${order.items.length} item(s) · ${order.status}`, externalId: `order-${order.id}`, custom: { orderNumberStr: String(order.number), orderStatusStr: order.status, totalPriceNum: order.total, currencyStr: order.currency, orderedAt: order.createdAt, shippedAt: order.shippedAt ?? null, carrierStr: order.carrier ?? null, trackingUrl: order.trackingUrl ?? null, adminUrl: `https://admin.store.example/orders/${order.id}`, }, data: { items: order.items.map((i) => ({ sku: i.sku, name: i.name, qty: i.quantity, price: i.price })) }, }) const found = await kfetch(`/v1/klasses/${KLASS}/externalId=order-${order.id}`) if (found.status === 404) return kfetch(`/v1/customers/${customerId}/klasses/${KLASS}`, { method: 'POST', body }) const { data } = await found.json() return kfetch(`/v1/klasses/${KLASS}/${data.id}`, { method: 'PUT', body }) } ``` `POST /v1/customers/{id}/klasses/{name}` ([reference](https://developer.kustomer.com/api/core-resources/create-customer-kobject-custom-object)) `PUT /v1/klasses/{name}/{id}` ([reference](https://developer.kustomer.com/api/core-resources/update-kobject-custom-object-by-id)) For the initial import of past orders, use [bulk creation](https://developer.kustomer.com/api/core-resources/bulk-create-kobjects-custom-objects) with `importedAt` set, 100 orders per job. ## 3. Link conversations to orders Add an `Id` attribute to conversations whose target is the order Klass. Agents (or your automation) can then pick the order a conversation is about: ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/metadata/conversation" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "orderId": { "displayName": "Order", "target": "kobject.ecomm-order" } } }' ``` Set it when you can infer the order, for example from an order number in the message: ```ts title="Node.js" const match = /#?(\d{4,})/.exec(message.preview) if (match) { const order = await kfetch(`/v1/klasses/ecomm-order/externalId=order-${match[1]}`) if (order.ok) { const { data } = await order.json() await kfetch(`/v1/conversations/${conversationId}`, { method: 'PUT', body: JSON.stringify({ custom: { orderId: data.id } }) }) } } ``` Kustomer validates that `orderId` points to an existing `ecomm-order` KObject. ## 4. Show order details with a KView Orders already appear on the timeline with the Klass icon, `title` and `description`. A [KView](https://developer.kustomer.com/docs/apps/kviews) gives agents a richer card with line items and a tracking button. KViews are defined in an app definition, with a `resource` of `kobject.ecomm-order` and a JSX `template`. _Figure: An order KView in the Kustomer insight panel showing order number, status badge, line items table, total and a Track package button. An order KView in the insight panel._ ## 5. Automate with order data Because order attributes are typed and indexed, you can use them across Kustomer: - **Search:** find every customer with a delivered order over $500 in the last week: ```json { "queryContext": "customer", "and": [ { "kobject_custom_ecomm-order_orderStatusStr": { "equals": "delivered" } }, { "kobject_custom_ecomm-order_totalPriceNum": { "gte": 500 } }, { "kobject_updated_at": { "gte": "2026-10-14" } } ] } ``` - **Workflows:** trigger on KObject events for the order Klass, for example to send a proactive message when `orderStatusStr` changes to `delayed`. See [Workflows](https://developer.kustomer.com/docs/workflows). - **Routing:** route conversations about high-value orders to a priority queue using the linked `orderId`. ## Production notes - One Klass per object type. Don't create a Klass per store or per region; use an attribute instead. - Upserts by `externalId` make the sync safe to replay. Replay from your store's event log after an outage. - Each customer can have at most 120 KObjects created per minute by one client. For bursts, such as a flash sale, queue writes and use bulk jobs. --- # Model company hierarchies > Represent B2B accounts as trees of companies with the hierarchy API. Link parents and subsidiaries, read a company's parent, children and breadcrumbs, move and unlink branches, delete companies safely, and sync a tree from your CRM within the depth and size limits. Source: https://developer.kustomer.com/docs/guides/company-hierarchy B2B accounts are rarely flat. A parent company owns subsidiaries, which own brands or regional offices, and your agents need to see where a customer's company sits in that tree. The hierarchy API stores this structure as edges between existing companies. ```mermaid flowchart TD GM["General Mills
(root)"] --> C["Cheerios"] GM --> N["Nature Valley"] N --> NE["Nature Valley Europe"] N --> NA["Nature Valley North America"] ``` Each box is a company with its own conversations, attributes and custom objects. Each arrow is an **edge** recording which company is the parent. ## How hierarchies work - **Nodes are companies.** Create them with the [companies API](https://developer.kustomer.com/api/core-resources/create-company) first. The hierarchy endpoints only link companies that already exist. A company with no parent is a **root**. - **An edge is one parent-child link.** It's a resource of type `kedge` with its own ID, separate from either company's ID. `sourceId` is the parent and `targetId` is the child. - **Edges belong to a named tree.** `name` defaults to `company_hierarchy`. A company has at most one parent per tree, and trees are independent, so the same company can have a different parent in a `billing` tree than in a `sales` tree. - **Only companies are supported.** `sourceType`, `targetType`, `nodeType` and `parentType` default to `company`; any other value returns `400 badparam`. - **No extra roles.** Reads need `org.user.company.read` (or the permission-set equivalent `org.permission.company.read`), and writes need `org.user.company.write` (`org.permission.company.update`). > **Always send the tree name:** The default works, but pass `name` explicitly in scripts. Error messages for blocked deletes name the tree, and you'll reuse that exact string to fix them. ## Link companies Create the parent and child as usual, storing each Kustomer ID against your system's ID in `externalId`: ```bash curl -X POST "https://acme.api.kustomerapp.com/v1/companies" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "General Mills", "externalId": "erp-gm-001" }' ``` Then add an edge. There are two equivalent endpoints; use whichever matches the IDs you're holding. **Create an edge** `POST /v1/kedge` ([reference](https://developer.kustomer.com/api/core-resources/create-a-company-hierarchy-edge)) ```bash curl -X POST "https://acme.api.kustomerapp.com/v1/kedge" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sourceId": "PARENT_ID", "targetId": "CHILD_ID", "name": "company_hierarchy" }' ``` **Set a company's parent** `POST /v1/kedge/{id}/parent` ([reference](https://developer.kustomer.com/api/core-resources/set-a-companys-parent)) ```bash curl -X POST "https://acme.api.kustomerapp.com/v1/kedge/CHILD_ID/parent" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "parentId": "PARENT_ID", "name": "company_hierarchy" }' ``` Both return `201` with the edge: ```json { "data": { "type": "kedge", "id": "64f1a2b3c4d5e6f7a8b9c0d1", "attributes": { "name": "company_hierarchy", "relType": "tree", "sourceId": "PARENT_ID", "sourceType": "company", "targetId": "CHILD_ID", "targetType": "company", "createdAt": "2026-08-14T22:01:34.620Z", "updatedAt": "2026-08-14T22:01:34.620Z" } } } ``` If the child already has a parent in that tree, the new edge **replaces** it. Keep `data.id` if you plan to delete edges by ID later. Each edge is one request. Link from the root downward, so every write lands in a tree that's already within the [limits](#limits). ## Read the tree `GET /v1/kedge/{id}` ([reference](https://developer.kustomer.com/api/core-resources/get-a-company-hierarchy-node)) Pass a **company** ID. The response is a `kedge-node` with the company's parent, one page of its direct children, and the path from the root: ```bash curl "https://acme.api.kustomerapp.com/v1/kedge/PARENT_ID?name=company_hierarchy&page=1&pageSize=100" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` Children are one level deep. To walk the whole tree, page through `children` until `nextPage` is `null`, then read each child's `targetId` the same way. To find the root of any company, read it and take `breadcrumbs[0]`. [`GET /v1/kedge/{id}/parent`](https://developer.kustomer.com/api/core-resources/get-a-companys-direct-parent-edge) returns just the parent edge, without names, and responds `404` for a root. ```ts title="walk-tree.ts" const BASE = `https://${process.env.KUSTOMER_ORG}.api.kustomerapp.com/v1` const headers = { Authorization: `Bearer ${process.env.KUSTOMER_API_KEY}` } interface Edge { id: string; attributes: { targetId: string; displayName?: string } } /** Prints the subtree under `companyId`, one company per line, indented by depth. */ async function walk(companyId: string, tree = 'company_hierarchy', depth = 0): Promise { for (let page = 1; page; ) { const res = await fetch(`${BASE}/kedge/${companyId}?name=${tree}&page=${page}&pageSize=100`, { headers }) if (!res.ok) throw new Error(`${res.status} ${await res.text()}`) const { data, meta } = await res.json() if (page === 1) console.log(`${' '.repeat(depth)}${data.attributes.displayName}`) for (const child of data.attributes.children as Edge[]) await walk(child.attributes.targetId, tree, depth + 1) page = meta.nextPage } } await walk(process.argv[2]) ``` ## Move and unlink | To | Call | Result | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | Move a company to a new parent | [`POST /v1/kedge`](https://developer.kustomer.com/api/core-resources/create-a-company-hierarchy-edge) or [`POST /v1/kedge/{id}/parent`](https://developer.kustomer.com/api/core-resources/set-a-companys-parent) again, with the same `name` | The old parent edge is replaced. The company's own children move with it. | | Make a company a root | [`DELETE /v1/kedge/{id}/parent?name=…`](https://developer.kustomer.com/api/core-resources/remove-a-companys-parent) with the **company** ID | `204`. The company and its children stay; it becomes a root. | | Delete one edge | [`DELETE /v1/kedge/{id}`](https://developer.kustomer.com/api/core-resources/delete-a-company-hierarchy-edge) with the **edge** ID | `204`, or `404` if it doesn't exist. | A company can't be its own parent, and can't be placed under one of its own descendants. ## Delete companies Companies are deleted by setting `deleted: true`, not with a `DELETE` request: ```bash title="One company" curl -X PUT "https://acme.api.kustomerapp.com/v1/companies/COMPANY_ID" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "deleted": true }' ``` ```bash title="Up to 100, each listed" curl -X PUT "https://acme.api.kustomerapp.com/v1/companies/bulk" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '[{ "id": "COMPANY_ID_1", "deleted": true }, { "id": "COMPANY_ID_2", "deleted": true }]' ``` ```bash title="Up to 100, same change" curl -X PUT "https://acme.api.kustomerapp.com/v1/companies/bulk?ids=COMPANY_ID_1,COMPANY_ID_2" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "deleted": true }' ``` Bulk updates run asynchronously; see [bulk operations](https://developer.kustomer.com/docs/guides/bulk-operations) for polling the result. > **Parents can't be deleted:** Deleting a company that still has children in **any** tree returns `409 conflict`, naming the first blocking child and its tree, and leaves the company in place. Unlink that child with `DELETE /v1/kedge/{childId}/parent?name=` using the tree name from the error, or delete the child first, then retry. To remove a whole branch, work from the leaves up. ## Limits - A tree can be at most **20 levels** deep, from root to leaf. - A tree can hold at most **2,000 companies**. This is checked when an edge moves a subtree into another tree. - You can have any number of roots. Each root's tree has its own limits. Wide levels are paged, never truncated. | Status | `code` | Cause | | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------- | | `400` | `badparam` | A `sourceId`, `targetId` or `parentId` doesn't reference an existing company you can read, or the parent has been deleted | | `400` | `badparam` | The parent and child are the same company, or the edge would create a cycle | | `400` | `badparam` | The edge would exceed 20 levels or 2,000 companies | | `400` | `badparam` | An unsupported `sourceType`, `targetType`, `nodeType` or `parentType` | | `404` | `not_found` | Reading the parent of a root, or deleting an edge ID that doesn't exist | | `409` | `conflict` | Another write to the same tree is in progress, or the edge changed during a delete. Retry the request. | | `409` | `conflict` | Deleting a company that's still a parent (see above) | ## Sync a hierarchy from your CRM #### Create or match every company Upsert companies by `externalId` (see [Sync customers from a CRM](https://developer.kustomer.com/docs/guides/sync-customers-from-crm#3-companies)) and store each Kustomer company ID next to your record. For large loads, use [bulk create](https://developer.kustomer.com/api/core-resources/bulk-create-companies) and take IDs from the bulk result. #### Link from the root down One edge per parent-child pair, always with the same `name`. Process parents before children so no write exceeds the depth limit mid-sync. #### Verify Read each parent with `GET /v1/kedge/{id}` and compare `meta.total` with the number of direct children in your CRM. #### Apply changes incrementally When a company moves, send the edge again with its new parent. When the CRM drops a relationship, call `DELETE /v1/kedge/{id}/parent`. Retry single writes that return `409 conflict`. > **For AI agents:** Edge IDs and company IDs are both 24-character hex strings. `GET /v1/kedge/{id}` and `DELETE /v1/kedge/{id}/parent` take a **company** ID; only `DELETE /v1/kedge/{id}` takes an **edge** ID. --- # Bulk operations > Create, update and delete up to 100 records per request with Kustomer's asynchronous bulk API, then poll for completion and match per-item results with _clientRef. Source: https://developer.kustomer.com/docs/guides/bulk-operations Bulk endpoints accept up to **100 records per request** and process them asynchronously. One bulk request uses one unit of your [rate limit](https://developer.kustomer.com/docs/concepts/rate-limits) instead of 100, which makes bulk the right tool for imports, nightly syncs and backfills. ```mermaid sequenceDiagram autonumber participant You participant API as Kustomer API You->>API: POST /v1/customers/bulk [ …up to 100 items ] API-->>You: 201 { data: { type: "bulk", id, status: "queued" } } loop until status = complete You->>API: GET /v1/bulk/{id} API-->>You: { status: queued | running | complete, batches: { complete, total } } end You->>API: GET /v1/bulk/{id}/batches API-->>You: [ { successCount, failureCount, objectIds, results[] } ] ``` ## Supported resources | Resource | Create | Update | Delete | | ------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Customers | [`POST /v1/customers/bulk`](https://developer.kustomer.com/api/core-resources/bulk-create-customers) | [`PUT /v1/customers/bulk`](https://developer.kustomer.com/api/core-resources/bulk-batch-update-customers) | [`DELETE /v1/customers/bulk`](https://developer.kustomer.com/api/core-resources/bulk-delete-customers) | | Companies | [`POST /v1/companies/bulk`](https://developer.kustomer.com/api/core-resources/bulk-create-companies) | [`PUT /v1/companies/bulk`](https://developer.kustomer.com/api/core-resources/bulk-batch-update-companies) | | | Conversations | [`POST /v1/conversations/bulk`](https://developer.kustomer.com/api/core-resources/bulk-create-conversations) | [`PUT /v1/conversations/bulk`](https://developer.kustomer.com/api/core-resources/bulk-batch-update-conversations) | [`DELETE /v1/conversations/bulk`](https://developer.kustomer.com/api/core-resources/bulk-delete-conversations) | | Messages | [`POST /v1/messages/bulk`](https://developer.kustomer.com/api/core-resources/bulk-create-messages) | [`PUT /v1/messages/bulk`](https://developer.kustomer.com/api/core-resources/bulk-batch-update-messages) | | | Notes | [`POST /v1/notes/bulk`](https://developer.kustomer.com/api/core-resources/bulk-create-notes) | [`PUT /v1/notes/bulk`](https://developer.kustomer.com/api/core-resources/bulk-batch-update-notes) | | | KObjects | [`POST /v1/klasses/{name}/bulk`](https://developer.kustomer.com/api/core-resources/bulk-create-kobjects-custom-objects) | [`PUT /v1/klasses/{name}/bulk`](https://developer.kustomer.com/api/core-resources/bulk-batch-update-kobjects-custom-objects) | [`DELETE /v1/klasses/{name}/bulk`](https://developer.kustomer.com/api/core-resources/bulk-delete-kobjects-custom-objects) | Bulk deletes take `?ids=` with a comma-separated list. ## 1. Submit a job Send an array of the same objects you'd send to the single-record endpoint. Add `_clientRef` to each item so you can match results back to your source rows: ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/customers/bulk" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '[ { "_clientRef": "crm-001", "name": "Alice Smith", "externalId": "crm-001", "emails": [{ "type": "home", "email": "alice@example.com" }] }, { "_clientRef": "crm-002", "name": "Bob Jones", "externalId": "crm-002", "emails": [{ "type": "home", "email": "bob@example.com" }] } ]' ``` The response is a bulk operation. Store its `id`: ```json title="201 Created" { "data": { "type": "bulk", "id": "5b3bddb67f8469003df370a4", "attributes": { "status": "queued" } } } ``` ### `_clientRef` rules - Optional, but **all or nothing**: if any item has `_clientRef`, every item must. A mix returns `400`. - A non-empty string of up to 255 characters, unique within the request. - Supported on creates and on array-shaped updates. Not supported on `?ids=` updates or on deletes. ## 2. Poll for completion `GET /v1/bulk/{id}` ([reference](https://developer.kustomer.com/api/core-resources/get-bulk-operation-by-id)) | Status | Meaning | | ---------- | ------------------------------------------------------------------ | | `queued` | Accepted and waiting. You can submit many jobs and let them queue. | | `running` | A worker is processing the job. | | `complete` | Finished. Read the batches for per-item results. | `batches.complete` and `batches.total` show progress, and `batchErrors` is `true` if any batch had failures. Poll every few seconds with backoff. Each poll uses your rate limit. ## 3. Read the results `GET /v1/bulk/{id}/batches` ([reference](https://developer.kustomer.com/api/core-resources/get-bulk-operation-batches)) ```json title="200 OK" { "data": [ { "type": "bulk_batch", "id": "507f1f77bcf86cd799439021", "attributes": { "status": "complete", "successCount": 2, "failureCount": 1, "objectIds": ["507f1f77bcf86cd799439031", "507f1f77bcf86cd799439032"], "results": [ { "_clientRef": "crm-001", "objectId": "507f1f77bcf86cd799439031", "status": "created" }, { "_clientRef": "crm-002", "objectId": "507f1f77bcf86cd799439032", "status": "created" }, { "_clientRef": "crm-003", "status": "error", "error": "duplicate detected" } ], "errors": [] } } ] } ``` Each `results` entry has `status` (`created`, `updated` or `error`), the `objectId` on success, and an `error` reason on failure. `results` only appears when you sent `_clientRef`. Without it, use `objectIds`, or look records up by `externalId` once the job completes. ## Updates and deletes **Different changes per record** Send an array; each item needs its `id`: ```json title="PUT /v1/customers/bulk" [ { "_clientRef": "crm-001", "id": "507f1f77bcf86cd799439031", "custom": { "loyaltyTierStr": "gold" } }, { "_clientRef": "crm-002", "id": "507f1f77bcf86cd799439032", "custom": { "loyaltyTierStr": "silver" } } ] ``` **Same change to many records** Pass the IDs in the query string and one object in the body: ```bash curl -X PUT "https://acme.api.kustomerapp.com/v1/customers/bulk?ids=507f1f77bcf86cd799439031,507f1f77bcf86cd799439032" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tags": ["migrated"] }' ``` **Delete** ```bash curl -X DELETE "https://acme.api.kustomerapp.com/v1/customers/bulk?ids=507f1f77bcf86cd799439031,507f1f77bcf86cd799439032" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` Updates merge by default. Add `?replace=true` to replace each record entirely. ## A complete import script ```ts title="Node.js" const base = `https://${process.env.KUSTOMER_ORG}.api.kustomerapp.com` const headers = { Authorization: `Bearer ${process.env.KUSTOMER_API_KEY}`, 'Content-Type': 'application/json' } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) type Row = { id: string; name: string; email: string } async function importCustomers(rows: Row[]) { const failures: { ref: string; error: string }[] = [] for (let i = 0; i < rows.length; i += 100) { const chunk = rows.slice(i, i + 100) const body = chunk.map((r) => ({ _clientRef: r.id, externalId: r.id, name: r.name, emails: [{ type: 'home', email: r.email }] })) const job = await fetch(`${base}/v1/customers/bulk`, { method: 'POST', headers, body: JSON.stringify(body) }).then((r) => r.json()) let status = job.data.attributes.status for (let delay = 1000; status !== 'complete'; delay = Math.min(delay * 2, 15000)) { await sleep(delay) status = (await fetch(`${base}/v1/bulk/${job.data.id}`, { headers }).then((r) => r.json())).data.attributes.status } const batches = await fetch(`${base}/v1/bulk/${job.data.id}/batches`, { headers }).then((r) => r.json()) for (const b of batches.data) for (const r of b.attributes.results ?? []) if (r.status === 'error') failures.push({ ref: r._clientRef, error: r.error }) } return failures } ``` ## Tips - **Set `externalId` as well as `_clientRef`.** `_clientRef` matches results within one job; `externalId` lets you find the record forever. - **Use `importedAt` for historical data.** It exempts conversations, messages and KObjects from the per-customer create limit. - **Order matters across jobs.** Create customers before their conversations, and conversations before their messages. Wait for each job to complete before you submit dependent records. - **Don't send duplicates in one job.** Two items with the same `externalId` in a single request can race. --- # Upload attachments > Attach files to messages, notes and drafts using presigned uploads, register files that stay in your own storage, and download attachments. Source: https://developer.kustomer.com/docs/guides/attachments Attachments are created in two steps: first you create an **attachment record** that describes the file, then you either **upload the bytes** with the presigned form Kustomer returns, or point Kustomer at a file you already host. Finally you reference the attachment from a message, note or draft. ```mermaid sequenceDiagram autonumber participant You participant API as Kustomer API participant S as Upload URL You->>API: POST /v1/attachments { name, contentType, contentLength } API-->>You: 201 { data.id, meta.upload: { url, fields } } You->>S: POST multipart/form-data (fields…, file) S-->>You: 204 No Content You->>API: POST /v1/conversations/{id}/messages { …, attachments: [{ _id, name, contentType, contentLength }] } ``` ## 1. Create the attachment record `POST /v1/attachments` ([reference](https://developer.kustomer.com/api/core-resources/create-attachment)) ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/attachments" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "invoice-4417.pdf", "contentType": "application/pdf", "contentLength": 48213 }' ``` `name`, `contentType` and `contentLength` (in bytes) are required. The response includes the attachment `id` and a temporary upload policy: ```json title="201 Created" { "data": { "id": "5a4d1a8a6d92880012ad2923", "type": "attachment", "attributes": { "name": "invoice-4417.pdf", "contentType": "application/pdf", "contentLength": 48213, "uploaded": false } }, "meta": { "upload": { "url": "https://…", "fields": { "key": "attachments/…/5a4d1a8a6d92880012ad2923-invoice-4417.pdf", "Content-Type": "application/pdf", "Policy": "…", "X-Amz-Signature": "…" } } } } ``` ## 2. Upload the file Send a `multipart/form-data` POST to `meta.upload.url`. Include **every** key in `meta.upload.fields` as a form field, exactly as returned, and add the file **last**, under the name `file`. Don't send your Kustomer `Authorization` header to this URL. ```bash title="cURL" # Build -F flags from meta.upload.fields, then append the file last curl -X POST "$UPLOAD_URL" \ -F "key=$KEY" \ -F "Content-Type=application/pdf" \ -F "Policy=$POLICY" \ -F "X-Amz-Signature=$SIGNATURE" \ -F "file=@invoice-4417.pdf" ``` ```ts title="Node.js" import { readFile } from 'node:fs/promises' const bytes = await readFile('invoice-4417.pdf') const created = await fetch(`${base}/v1/attachments`, { method: 'POST', headers, body: JSON.stringify({ name: 'invoice-4417.pdf', contentType: 'application/pdf', contentLength: bytes.length }), }).then((r) => r.json()) const { url, fields } = created.meta.upload const form = new FormData() for (const [k, v] of Object.entries(fields)) form.append(k, v) form.append('file', new Blob([bytes], { type: 'application/pdf' }), 'invoice-4417.pdf') const upload = await fetch(url, { method: 'POST', body: form }) if (!upload.ok) throw new Error(`upload failed: ${upload.status}`) ``` ```python title="Python" with open("invoice-4417.pdf", "rb") as f: data = f.read() created = requests.post(f"{base}/v1/attachments", headers=headers, json={ "name": "invoice-4417.pdf", "contentType": "application/pdf", "contentLength": len(data), }).json() upload = created["meta"]["upload"] res = requests.post(upload["url"], data=upload["fields"], files={"file": ("invoice-4417.pdf", data, "application/pdf")}) res.raise_for_status() ``` The upload policy expires after a short time, so upload right after creating the record. `contentLength` must match the file you upload. ## 3. Attach it Reference the attachment when you create the message or note. Messages accept attachment objects: ```json title="POST /v1/conversations/{id}/messages" { "channel": "email", "app": "acme-billing", "direction": "out", "subject": "Your invoice", "preview": "Hi Jane, your invoice for order #4417 is attached.", "attachments": [ { "_id": "5a4d1a8a6d92880012ad2923", "name": "invoice-4417.pdf", "contentType": "application/pdf", "contentLength": 48213 } ] } ``` Notes take the same shape. See [Create note within conversation](https://developer.kustomer.com/api/core-resources/create-note-within-conversation). ## Files in your own storage If files must stay in your storage, register them by URL instead of uploading. Pass `externalUrl`: ```json title="POST /v1/attachments" { "name": "contract.pdf", "contentType": "application/pdf", "contentLength": 120934, "externalUrl": "https://files.acme.example/contracts/8812.pdf", "renderMode": "default" } ``` - No upload policy is returned, and the attachment is marked `uploaded: true` straight away. - `externalUrl` must be an absolute `https://` URL without credentials in it. Private and loopback hosts are rejected. - Kustomer doesn't fetch, scan, copy or delete the file. Keeping the URL working is up to you; a signed URL that expires will break the attachment. | `renderMode` | Behavior | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `default` | Clients get the URL in `links.related` and can open or download the file. | | `metadata_only` | The URL is never returned to clients. Agents and SDKs see a non-interactive placeholder with the file's name and size. Use this for files that must only be opened in your own system. | ## Draft attachments Drafts have their own attachment endpoint. The response's `links.related` is a presigned URL that you upload to with a `PUT`: `POST /v1/drafts/{id}/attachments` ([reference](https://developer.kustomer.com/api/core-resources/create-draft-attachment)) ```bash curl --upload-file invoice-4417.pdf "$PRESIGNED_URL" ``` ## Downloading attachments `GET /v1/messages/{message_id}/attachments/{id}` ([reference](https://developer.kustomer.com/api/core-resources/get-attachment-by-message-id-and-attachment-id-inbound-or-outbound)) This works for inbound and outbound message attachments. The response's `links.related` is a temporary URL for the file content. Download it promptly and don't store the URL. ## Limits and tips - Create the attachment record and upload in one go. Records whose upload never completes stay `uploaded: false`. - Set `contentType` accurately: agents' browsers use it to preview images and PDFs. - Attachment uploads count toward your rate limit only for the Kustomer API calls, not the upload itself. --- # Search recipes > Copy-paste Kustomer search queries for common tasks, plus patterns for exporting beyond the 100-page limit, incremental sync and counting. Source: https://developer.kustomer.com/docs/guides/search-recipes Ready-to-use bodies for [`POST /v1/customers/search`](https://developer.kustomer.com/api/core-resources/create-customer-search). See [Search](https://developer.kustomer.com/docs/concepts/search) for the full syntax and every attribute. ## Conversations **Open, high priority** ```json { "queryContext": "conversation", "and": [ { "conversation_status": { "equals": "open" } }, { "conversation_priority": { "gte": 4 } } ] } ``` **Unassigned** ```json { "queryContext": "conversation", "and": [ { "conversation_status": { "in": ["open", "snoozed"] } }, { "conversation_assigned_users": { "not_set": "" } } ] } ``` **Created this week (local time)** ```json { "queryContext": "conversation", "and": [{ "conversation_created_at": { "gte": "2026-10-19" } }], "timeZone": "America/Los_Angeles" } ``` **By custom attribute** ```json { "queryContext": "conversation", "and": [{ "conversation_custom_orderNumberStr": { "equals": "4417" } }] } ``` ## Customers **By email domain** ```json { "queryContext": "customer", "and": [{ "customer_email": { "contains": "@acme.example" } }] } ``` **With an open conversation** ```json { "queryContext": "customer", "and": [{ "conversation_status": { "equals": "open" } }], "sort": [{ "customer_last_message_at": "desc" }] } ``` **VIPs not seen in 30 days** ```json { "queryContext": "customer", "and": [ { "customer_custom_loyaltyTierStr": { "equals": "gold" } }, { "customer_last_seen_at": { "lt": "2026-09-21" } } ] } ``` ## Messages and custom objects **Messages mentioning a refund** ```json { "queryContext": "message", "and": [ { "message_body": { "contains": "refund" } }, { "message_direction": { "equals": "in" } }, { "message_created_at": { "gte": "2026-10-01" } } ] } ``` **Orders by status** ```json { "queryContext": "kobject_ecomm-order", "and": [{ "kobject_custom_ecomm-order_orderStatusStr": { "equals": "shipped" } }] } ``` Check attribute names against the [standard attributes tables](https://developer.kustomer.com/docs/concepts/search#standard-attributes-json-schema). A misspelled attribute returns an error or no results. ## Export more than the page limit Search stops at **10,000 results** per query: a request where `page × pageSize` is over 10,000 returns `400`, so with `pageSize=500` the last page you can read is 20. To export everything, split the query into time windows on `updated_at` and page through each window. Make each window small enough to stay under the limit. ```ts title="Node.js" async function* exportConversations(from: Date, to: Date, windowHours = 24) { for (let start = from; start < to; start = new Date(start.getTime() + windowHours * 3_600_000)) { const end = new Date(Math.min(start.getTime() + windowHours * 3_600_000, to.getTime())) const body = { queryContext: 'conversation', and: [ { conversation_updated_at: { gte: start.toISOString() } }, { conversation_updated_at: { lt: end.toISOString() } }, ], } for (let page = 1; ; page++) { const res = await kfetch(`/v1/customers/search?page=${page}&pageSize=500`, { method: 'POST', body: JSON.stringify(body) }) const { data, meta } = await res.json() yield* data if (page >= (meta.totalPages ?? 0)) break if (page * 500 >= 10_000) throw new Error(`Window ${start.toISOString()} has more than 10,000 results; use a smaller window`) } } } ``` UTC timestamps (with `Z`) are used exactly, so adjacent windows with `gte` and `lt` never overlap or leave gaps. ## Incremental sync After a full export, only fetch what changed since your last run: ```ts title="Node.js" const since = await store.get('lastRun') // ISO timestamp const runStartedAt = new Date().toISOString() for await (const c of exportConversations(new Date(since), new Date(runStartedAt), 6)) await upsertLocal(c) await store.set('lastRun', runStartedAt) ``` Store the time the run **started**, not finished, so records updated during the run are picked up next time. Search updates asynchronously, so overlap windows by a few minutes if you can tolerate duplicates. ## Count without fetching Use `pageSize=1` and read `meta.total`: ```bash curl -X POST "https://acme.api.kustomerapp.com/v1/customers/search?pageSize=1" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "queryContext": "conversation", "and": [{ "conversation_status": { "equals": "open" } }] }' \ | jq '.meta.total' ``` ## IDs only For large syncs where you'll fetch records separately, add `?idsOnly=true` to return only matching IDs. ## Records older than two years Search only covers records updated in the last two years. Send the same body to [archive search](https://developer.kustomer.com/api/core-resources/create-customer-archive-search) for older data. ## Remember the limits - Machine users can make **100 search requests per minute**. - Sorting only works on **customer attributes**. - Results reflect the search index, which can lag writes by a moment. --- # Events and webhooks > How data moves in and out of Kustomer as events. Outbound webhooks push changes to your systems, inbound hooks accept data from anywhere, and every change carries a before-and-after diff. Source: https://developer.kustomer.com/docs/events Every create and update in Kustomer produces an **event**: a JSON envelope with the full resource and a diff of what changed. Workflows react to events internally. Outbound webhooks deliver a subset of them to your servers. Inbound hooks turn data you send into events that start workflows. ```mermaid flowchart LR subgraph In["Into Kustomer"] API[REST API] Hooks[Inbound hooks
form · web · email] end subgraph K["Kustomer"] Store[(Records)] Bus{{Events}} WF[Workflows] end subgraph Out["Out of Kustomer"] OWH[Outbound webhooks] REST[Workflow REST API steps] end API --> Store --> Bus Hooks --> Bus Bus --> WF WF --> Store Bus --> OWH --> You[(Your service)] WF --> REST --> You ``` _A live feed of simulated outbound webhook deliveries. Each row shows an event name such as conversation.update and its delivery status. Selecting a row shows the HTTP request with X-Kustomer-Webhook-Event and X-Kustomer-Webhook-Signature headers, the JSON body, and a timeline of delivery attempts on the 1, 1, 2, 5, 10, 15 and 30 minute retry schedule. A toggle switches the receiver between healthy, flaky and down._ ## Choose a mechanism | You want to | Use | Notes | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | | Mirror customers, conversations or messages into another system | [Outbound webhooks](https://developer.kustomer.com/docs/events/outbound-webhooks) | 12 event types, signed, retried for about an hour | | Call your API only when certain conditions are true | [Workflow](https://developer.kustomer.com/docs/workflows) with a [REST API step](https://developer.kustomer.com/docs/workflows/code-steps) | Filter first, shape the payload, use any trigger | | React to custom objects, notes or companies | A [workflow](https://developer.kustomer.com/docs/workflows/triggers-and-actions) | These events aren't available to outbound webhooks | | Send data into Kustomer from a form, script or partner | [Inbound hooks](https://developer.kustomer.com/docs/events/inbound-webhooks) | Each hook triggers a workflow | | Write records directly | The [REST API](https://developer.kustomer.com/docs/get-started/making-requests) | Writes produce events like any other change | | Backfill or reconcile | [Search](https://developer.kustomer.com/docs/guides/search-recipes#incremental-sync) | Webhooks don't replay history | ## The event envelope Outbound webhooks and workflows see the same envelope. `data` is the full resource as of the change, in the same [JSON:API shape](https://developer.kustomer.com/docs/concepts/json-api-format) the REST API returns. `changes` lists only the attributes and relationships that changed. ```json title="kustomer.conversation.update" { "id": "6717c2a4e5b1f00012ab34cd", "name": "kustomer.conversation.update", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "orgName": "acme", "data": { "type": "conversation", "id": "6717c29fe5b1f00012ab34c1", "attributes": { "name": "Where is my order?", "status": "done", "priority": 3, "channels": ["email"], "tags": ["5f9a1c2e8b3d4e0012345678"], "updatedAt": "2026-10-21T15:02:11.482Z" }, "relationships": { "customer": { "data": { "type": "customer", "id": "6717c29de5b1f00012ab34b9" } } }, "links": { "self": "/v1/conversations/6717c29fe5b1f00012ab34c1" } }, "createdAt": "2026-10-21T15:02:11.511Z", "changes": { "attributes": { "status": { "op": "replace", "before": "open", "after": "done" } }, "relationships": {} } } ``` **Envelope fields** > **Snapshots, not commands:** Treat each event as "this is what the record looks like now." Apply `data` idempotently, and use `changes` only to decide _whether_ to act. If two events for the same record arrive out of order, keep the one with the later `data.attributes.updatedAt`. ## What's in this section - [Outbound webhooks](https://developer.kustomer.com/docs/events/outbound-webhooks): Subscribe an HTTPS endpoint to Kustomer events through the API, the UI or an app definition. - [Event catalog](https://developer.kustomer.com/docs/events/event-catalog): Every event you can subscribe to, with payloads, plus the extra events available to workflows. - [Delivery and security](https://developer.kustomer.com/docs/events/delivery-and-retries): Verify signatures, handle retries and duplicates, and keep your webhook from being disabled. - [Inbound webhooks](https://developer.kustomer.com/docs/events/inbound-webhooks): Form, web and email hooks that bring outside data into workflows. --- # Outbound webhooks > Subscribe an HTTPS endpoint to Kustomer events. Create webhooks with the API, in Settings or from an app definition, then receive signed JSON for every customer, conversation, message, user, team and tag change. Source: https://developer.kustomer.com/docs/events/outbound-webhooks An outbound webhook is a URL plus a list of events. When one of those events happens in your org, Kustomer sends the [event envelope](https://developer.kustomer.com/docs/events#the-event-envelope) to the URL as a signed `POST`. #### Expose an HTTPS endpoint It must accept `POST` with a JSON body and answer with a `2xx` within a few seconds. Verify the signature before trusting the body. #### Create the webhook Use the API, **Settings › Platform › Outbound webhooks**, or the `outboundWebhooks` section of an [app definition](https://developer.kustomer.com/docs/apps/app-definition). #### Store the token The response includes a `token`. It's the HMAC key for `X-Kustomer-Webhook-Signature`. Keep it in your secret store. #### Watch deliveries List recent delivery attempts per webhook to debug failures. ## Create a webhook with the API `POST /v1/outbound-webhooks` ([reference](https://developer.kustomer.com/api/apps-platform/create-outbound-webhook)) ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/outbound-webhooks" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Conversation sync", "url": "https://hooks.example.com/kustomer", "events": ["kustomer.conversation.create", "kustomer.conversation.update", "kustomer.message.create"], "headers": [{ "name": "X-Source", "value": "kustomer" }] }' ``` ```ts title="Node.js" const res = await fetch('https://acme.api.kustomerapp.com/v1/outbound-webhooks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.KUSTOMER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Conversation sync', url: 'https://hooks.example.com/kustomer', events: ['kustomer.conversation.create', 'kustomer.conversation.update', 'kustomer.message.create'], headers: [{ name: 'X-Source', value: 'kustomer' }], }), }) const { data } = await res.json() await secrets.put('kustomer-webhook-token', data.attributes.token) ``` ```python title="Python" import os, requests res = requests.post( "https://acme.api.kustomerapp.com/v1/outbound-webhooks", headers={"Authorization": f"Bearer {os.environ['KUSTOMER_API_KEY']}"}, json={ "name": "Conversation sync", "url": "https://hooks.example.com/kustomer", "events": ["kustomer.conversation.create", "kustomer.conversation.update", "kustomer.message.create"], "headers": [{"name": "X-Source", "value": "kustomer"}], }, ) token = res.json()["data"]["attributes"]["token"] ``` ```bash title="kusty" cat > webhook.json <<'EOF' { "name": "Conversation sync", "url": "https://hooks.example.com/kustomer", "events": ["kustomer.conversation.create", "kustomer.conversation.update", "kustomer.message.create"] } EOF kusty api POST /v1/outbound-webhooks --data @webhook.json --output json ``` **Request body** _An outbound webhook builder. Fill in a name and URL (non-HTTPS URLs show Not a valid absolute HTTPS URL), toggle create and update events for conversations, customers, messages, users, teams and tags, and add custom headers, with a warning when one reuses a header Kustomer sets. The create request updates in cURL, Node.js, Python and app definition form. Beside it, a sample delivery for a chosen event shows the POST with Content-Type, X-Kustomer-Webhook-Event and an HMAC-SHA256 X-Kustomer-Webhook-Signature computed in the browser, then the body. A verify panel takes a webhook token and compares the signature against what a receiver computes from the raw body, a re-serialized body or a pretty-printed body: only the raw body matches._ ```json title="201 Created" { "data": { "type": "outbound-webhook", "id": "5f3e8f9b2b3fc60013f6b836", "attributes": { "name": "Conversation sync", "enabled": true, "url": "https://hooks.example.com/kustomer", "isError": false, "events": ["kustomer.conversation.create", "kustomer.conversation.update", "kustomer.message.create"], "headers": [{ "name": "X-Source", "value": "kustomer" }], "token": "f1253ac0dacd0a8fdbc5b8caac814865b53b164acb83f9163d5adad0195a6771…", "createdAt": "2026-10-21T14:58:35.499Z", "updatedAt": "2026-10-21T14:58:35.499Z" } } } ``` `isError` is `true` while Kustomer has [automatically disabled](https://developer.kustomer.com/docs/events/delivery-and-retries#automatic-disabling) the webhook. > **Permissions:** Managing outbound webhooks needs a key whose role includes `org.permission.outbound_webhook`, which administrators have. How many webhooks an org can have depends on its plan. ## Receive events A minimal receiver verifies the signature against the **raw** body, acknowledges immediately, then processes the event asynchronously. ```ts title="Node.js (Express)" import crypto from 'node:crypto' import express from 'express' const app = express() const TOKEN = process.env.KUSTOMER_WEBHOOK_TOKEN! app.post('/kustomer', express.raw({ type: 'application/json' }), (req, res) => { const expected = crypto.createHmac('sha256', TOKEN).update(req.body).digest('hex') const received = req.get('X-Kustomer-Webhook-Signature') ?? '' if (received.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) { return res.sendStatus(401) } res.sendStatus(200) queue.publish(JSON.parse(req.body.toString('utf8'))) }) ``` ```python title="Python (Flask)" import hashlib, hmac, json, os from flask import Flask, request, abort app = Flask(__name__) TOKEN = os.environ["KUSTOMER_WEBHOOK_TOKEN"].encode() @app.post("/kustomer") def kustomer(): raw = request.get_data() expected = hmac.new(TOKEN, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Kustomer-Webhook-Signature", "")): abort(401) queue.publish(json.loads(raw)) return "", 200 ``` ```go title="Go" func kustomer(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) mac := hmac.New(sha256.New, []byte(os.Getenv("KUSTOMER_WEBHOOK_TOKEN"))) mac.Write(raw) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Kustomer-Webhook-Signature"))) { http.Error(w, "bad signature", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) go handle(raw) } ``` Read [Delivery and security](https://developer.kustomer.com/docs/events/delivery-and-retries) before going to production. It covers retries, duplicates, ordering and automatic disabling. ## Manage webhooks | Task | Endpoint | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | List webhooks | [`GET /v1/outbound-webhooks`](https://developer.kustomer.com/api/apps-platform/list-outbound-webhooks) | | Get one, including its `token` | [`GET /v1/outbound-webhooks/{id}`](https://developer.kustomer.com/api/apps-platform/get-outbound-webhook) | | Change URL, events or headers, or set `enabled` | [`PATCH /v1/outbound-webhooks/{id}`](https://developer.kustomer.com/api/apps-platform/update-outbound-webhook) | | Delete | [`DELETE /v1/outbound-webhooks/{id}`](https://developer.kustomer.com/api/apps-platform/delete-outbound-webhook) | | List events you can subscribe to | [`GET /v1/outbound-webhooks/events`](https://developer.kustomer.com/api/apps-platform/list-subscribable-events) | | See the 25 most recent delivery attempts for one webhook | [`GET /v1/outbound-webhooks/{webhookId}/transactions`](https://developer.kustomer.com/api/apps-platform/list-transactions-for-webhook) | | See recent delivery attempts across all webhooks | [`GET /v1/outbound-webhooks/transactions`](https://developer.kustomer.com/api/apps-platform/list-transactions) | Pause a webhook during maintenance with `PATCH { "enabled": false }`. Events that happen while it's disabled are not queued for later, so reconcile afterwards with [incremental sync](https://developer.kustomer.com/docs/guides/search-recipes#incremental-sync). ## Define webhooks in an app Apps can create webhooks when they're installed. Add an `outboundWebhooks` array to the app definition with the same fields: ```json title="app definition" { "app": "ecommstore", "version": "1.2.0", "title": "E-comm Store", "outboundWebhooks": [ { "name": "Send conversation events to E-comm Store", "url": "https://api.ecommstore.example/kustomer/events", "headers": [{ "name": "X-Source", "value": "kustomer" }], "events": ["kustomer.conversation.create", "kustomer.conversation.update"] } ] } ``` > **Public apps are reviewed:** If a public app uses outbound webhooks, they must be essential to the app, and Kustomer may ask for a data-handling agreement and security details before listing it. See [Marketplace submission](https://developer.kustomer.com/docs/apps/marketplace). ## Webhooks or workflows? Outbound webhooks send every event of the subscribed types, with a fixed payload. If you only care about a subset ("conversations tagged `vip` that just closed") or need a different payload shape, use a [workflow](https://developer.kustomer.com/docs/workflows) with a condition and a [REST API step](https://developer.kustomer.com/docs/workflows/code-steps) instead. Workflows can also react to events webhooks don't offer, such as custom objects and notes. --- # Event catalog > Every event an outbound webhook can subscribe to, with payload examples and the attributes that matter, plus the additional events that only workflows can react to. Source: https://developer.kustomer.com/docs/events/event-catalog Outbound webhooks can subscribe to **12 events**: create and update for six resources. The authoritative list for your org comes from the API: `GET /v1/outbound-webhooks/events` ([reference](https://developer.kustomer.com/api/apps-platform/list-subscribable-events)) ```json title="200 OK" { "events": [ "kustomer.conversation.create", "kustomer.conversation.update", "kustomer.customer.create", "kustomer.customer.update", "…" ] } ``` ## Subscribable events | Event | Fires when | `data.type` | | ------------------------------ | ------------------------------------------------------------------------------------ | -------------- | | `kustomer.customer.create` | A customer is created by an agent, the API, chat, an import or a channel | `customer` | | `kustomer.customer.update` | Any customer attribute changes, including activity timestamps | `customer` | | `kustomer.conversation.create` | A conversation is created | `conversation` | | `kustomer.conversation.update` | Status, assignment, tags, custom attributes, or message counts and timestamps change | `conversation` | | `kustomer.message.create` | A message is sent or received on any channel | `message` | | `kustomer.message.update` | A message changes, for example its delivery `status` | `message` | | `kustomer.user.create` | A user (agent or machine user) is added | `user` | | `kustomer.user.update` | A user's profile, roles or teams change | `user` | | `kustomer.team.create` | A team is created | `team` | | `kustomer.team.update` | A team's name or members change | `team` | | `kustomer.tag.create` | A tag is created | `tag` | | `kustomer.tag.update` | A tag is renamed, recolored or deleted | `tag` | On create events, `changes` is `null`. Webhooks don't include delete events; detect deletions by reconciling with [search](https://developer.kustomer.com/docs/concepts/search) using `includeDeleted`. > **Update events are chatty:** `conversation.update` fires for every message, because message counts and `lastMessageAt` change. `customer.update` fires on activity timestamps. Filter on `changes` so you only act on the fields you care about. ## Payloads Payloads are trimmed below. The real `data` is the full resource, the same as `GET /v1//{id}` returns. **customer** ```json title="kustomer.customer.update" { "id": "6717c2a4e5b1f00012ab3500", "name": "kustomer.customer.update", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "data": { "type": "customer", "id": "6717c29de5b1f00012ab34b9", "attributes": { "name": "Jane Doe", "displayName": "Jane Doe", "externalId": "crm-10427", "emails": [{ "type": "home", "email": "jane@example.com", "verified": false }], "phones": [{ "type": "mobile", "phone": "+15555550123", "verified": false }], "tags": [], "custom": { "loyaltyTierStr": "gold" }, "conversationCounts": { "open": 1, "snoozed": 0, "done": 4, "all": 5 }, "lastActivityAt": "2026-10-21T15:00:02.118Z", "updatedAt": "2026-10-21T15:00:02.130Z" }, "relationships": {}, "links": { "self": "/v1/customers/6717c29de5b1f00012ab34b9" } }, "createdAt": "2026-10-21T15:00:02.151Z", "changes": { "attributes": { "custom": { "op": "replace", "before": { "loyaltyTierStr": "silver" }, "after": { "loyaltyTierStr": "gold" } }, "updatedAt": { "op": "replace", "before": "2026-10-20T09:12:44.001Z", "after": "2026-10-21T15:00:02.130Z" } }, "relationships": {} }, "client": "api" } ``` **conversation** ```json title="kustomer.conversation.update" { "id": "6717c2b1e5b1f00012ab3511", "name": "kustomer.conversation.update", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "data": { "type": "conversation", "id": "6717c29fe5b1f00012ab34c1", "attributes": { "name": "Where is my order?", "preview": "Hi, my package hasn't arrived yet", "channels": ["email"], "status": "open", "priority": 3, "messageCount": 2, "assignedUsers": ["5fe109880e432183796e9218"], "assignedTeams": [], "tags": [], "custom": {}, "lastMessageDirection": "in", "updatedAt": "2026-10-21T15:02:11.482Z" }, "relationships": { "customer": { "data": { "type": "customer", "id": "6717c29de5b1f00012ab34b9" }, "links": { "self": "/v1/customers/6717c29de5b1f00012ab34b9" } } }, "links": { "self": "/v1/conversations/6717c29fe5b1f00012ab34c1" } }, "createdAt": "2026-10-21T15:02:11.511Z", "changes": { "attributes": { "messageCount": { "op": "replace", "before": 1, "after": 2 }, "lastMessageDirection": { "op": "add", "after": "in" } }, "relationships": {} } } ``` **message** ```json title="kustomer.message.create" { "id": "6717c2b1e5b1f00012ab3510", "name": "kustomer.message.create", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "data": { "type": "message", "id": "6717c2b0e5b1f00012ab350c", "attributes": { "externalId": "SMbeb0497425a441669615f2874dc9a312", "channel": "sms", "app": "twilio", "direction": "in", "directionType": "initial-in", "preview": "Hi, my package hasn't arrived yet", "meta": { "from": "+15555550123", "to": "+15555550100" }, "status": "received", "auto": false, "sentAt": "2026-10-21T15:02:10.980Z", "createdAt": "2026-10-21T15:02:11.002Z" }, "relationships": { "customer": { "data": { "type": "customer", "id": "6717c29de5b1f00012ab34b9" } }, "conversation": { "data": { "type": "conversation", "id": "6717c29fe5b1f00012ab34c1" } } }, "links": { "self": "/v1/messages/6717c2b0e5b1f00012ab350c" } }, "createdAt": "2026-10-21T15:02:11.020Z", "changes": null } ``` **user** ```json title="kustomer.user.update" { "id": "60873348401293008e0f288e", "name": "kustomer.user.update", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "data": { "type": "user", "id": "608732dc0ac6b300195d5fb5", "attributes": { "name": "Gregor Samsa", "displayName": "Gregor", "userType": "user", "email": "gregor@example.com", "roleGroups": ["5f8481c859e436a2faea1470"], "updatedAt": "2026-10-21T09:41:12.004Z" }, "relationships": {}, "links": { "self": "/v1/users/608732dc0ac6b300195d5fb5" } }, "createdAt": "2026-10-21T09:41:12.020Z", "changes": { "attributes": { "displayName": { "op": "add", "after": "Gregor" } }, "relationships": {} } } ``` **team** ```json title="kustomer.team.update" { "id": "608733d0ca6e20001913be16", "name": "kustomer.team.update", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "data": { "type": "team", "id": "608733a7178c7116b82c5e9a", "attributes": { "name": "tier-2", "displayName": "Tier 2", "members": ["608732dc0ac6b300195d5fb5", "5fe109880e432183796e9218"], "deleted": false, "updatedAt": "2026-10-21T09:45:00.310Z" }, "relationships": {}, "links": { "self": "/v1/teams/608733a7178c7116b82c5e9a" } }, "createdAt": "2026-10-21T09:45:00.330Z", "changes": { "attributes": { "members": { "op": "replace", "before": ["5fe109880e432183796e9218"], "after": ["608732dc0ac6b300195d5fb5", "5fe109880e432183796e9218"] } }, "relationships": {} } } ``` **tag** ```json title="kustomer.tag.create" { "id": "6717c3f0e5b1f00012ab3601", "name": "kustomer.tag.create", "org": "5f8481c6a22ab5b9d5f2dd8a", "partition": "5f8481c6a22ab5b9d5f2dd8a", "data": { "type": "tag", "id": "6717c3f0e5b1f00012ab35ff", "attributes": { "name": "refund-request", "description": "Customer asked for a refund", "color": "#64943E", "deleted": false, "createdAt": "2026-10-21T15:07:28.400Z" }, "relationships": {}, "links": { "self": "/v1/tags/6717c3f0e5b1f00012ab35ff" } }, "createdAt": "2026-10-21T15:07:28.420Z", "changes": null } ``` Conversations store tag **IDs** in `tags`. Subscribe to tag events to keep an ID-to-name map up to date. ## Recipes ```ts title="Conversation closed" function closed(event: KustomerEvent) { const status = event.changes?.attributes?.status return event.name === 'kustomer.conversation.update' && status?.after === 'done' && status.before !== 'done' } ``` ```ts title="Custom attribute changed" function changedCustom(event: KustomerEvent, field: string) { const custom = event.changes?.attributes?.custom return !!custom && custom.before?.[field] !== custom.after?.[field] } ``` ```ts title="Inbound message only" function inboundMessage(event: KustomerEvent) { return event.name === 'kustomer.message.create' && event.data.attributes.direction === 'in' } ``` ```ts title="Reassigned" function reassigned(event: KustomerEvent) { return event.name === 'kustomer.conversation.update' && 'assignedUsers' in (event.changes?.attributes ?? {}) } ``` ## Events available to workflows only Workflows can react to more events than webhooks can. To push any of these to your systems, build a workflow with that trigger and a [REST API step](https://developer.kustomer.com/docs/workflows/code-steps). | Trigger | Fires when | | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kustomer.company.create`, `kustomer.company.update` | A company is created or changes | | `kustomer.note.create`, `kustomer.note.update` | An internal note is added or edited | | `kustomer.kobject..create`, `kustomer.kobject..update` | A custom object of that Klass is created or changes, for example `kustomer.kobject.ecomm-order.update` | | `kustomer.draft.create` | A draft reply is created | | `kustomer.internal_thread.create`, `kustomer.internal_message.create` | An internal thread or a message in one is created | | `kustomer.hooks...receive` | An [inbound hook](https://developer.kustomer.com/docs/events/inbound-webhooks) receives data | | `kustomer.workflow..call` | Another workflow calls or schedules a [callable workflow](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows) | See [Triggers and actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions) for what each trigger outputs. --- # Delivery and security > How Kustomer delivers outbound webhooks. HMAC-SHA256 signatures, the retry schedule, at-least-once delivery, ordering, timeouts, automatic disabling and how to debug failed deliveries. Source: https://developer.kustomer.com/docs/events/delivery-and-retries Kustomer delivers each event as an HTTPS `POST` and retries failures for about an hour. Your receiver should do three things: verify the signature, return `2xx` quickly, and tolerate duplicates and reordering. ## The request ```http POST /kustomer HTTP/1.1 Host: hooks.example.com Content-Type: application/json X-Kustomer-Webhook-Event: kustomer.conversation.update X-Kustomer-Webhook-Signature: 3f1c9a…e07b X-Source: kustomer {"id":"6717c2b1e5b1f00012ab3511","name":"kustomer.conversation.update",…} ``` | Header | Value | | ------------------------------ | --------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `X-Kustomer-Webhook-Event` | The event name, so you can route before parsing | | `X-Kustomer-Webhook-Signature` | Lowercase hex HMAC-SHA256 of the raw body, keyed with the webhook's `token` | | Your custom headers | Whatever you set in `headers` when creating the webhook | ## Verify signatures The `token` returned when you create the webhook is the signing secret. You can also read it later with [`GET /v1/outbound-webhooks/{id}`](https://developer.kustomer.com/api/apps-platform/get-outbound-webhook), or in **Settings › Platform › Outbound webhooks › Edit › Header information**. ```text signature = hex( HMAC_SHA256( key = token, message = raw request body ) ) ``` > **Sign the raw bytes:** Compute the HMAC over the body exactly as received. Parsing and re-serializing JSON changes whitespace and key order, and the signature won't match. In Express, use `express.raw()` on this route rather than `express.json()`. ```ts title="Node.js" import crypto from 'node:crypto' export function verifyKustomer(rawBody: Buffer, signature: string | undefined, token: string): boolean { if (!signature) return false const expected = crypto.createHmac('sha256', token).update(rawBody).digest('hex') return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) } ``` ```python title="Python" import hashlib, hmac def verify_kustomer(raw_body: bytes, signature: str | None, token: str) -> bool: if not signature: return False expected = hmac.new(token.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` ```go title="Go" func VerifyKustomer(rawBody []byte, signature, token string) bool { mac := hmac.New(sha256.New, []byte(token)) mac.Write(rawBody) return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature)) } ``` ```ruby title="Ruby" require "openssl" def verify_kustomer(raw_body, signature, token) return false if signature.nil? expected = OpenSSL::HMAC.hexdigest("SHA256", token, raw_body) Rack::Utils.secure_compare(expected, signature) end ``` ```php title="PHP" function verify_kustomer(string $rawBody, ?string $signature, string $token): bool { if ($signature === null) return false; $expected = hash_hmac('sha256', $rawBody, $token); return hash_equals($expected, $signature); } ``` Always compare in constant time (`timingSafeEqual`, `compare_digest`, `hmac.Equal`). A plain `===` leaks how many leading characters matched. ### Rotate the token There's no rotate endpoint. To rotate, create a second webhook with the same URL and events, update your receiver to accept either token, then delete the old webhook. ## Respond fast Kustomer waits up to **30 seconds** for a response. Return `200` as soon as the signature checks out, then do the work from a queue. Slow responses tie up deliveries and count as failures when they time out. ```mermaid sequenceDiagram participant K as Kustomer participant R as Your receiver participant Q as Queue participant W as Worker K->>R: POST event (signed) R->>R: verify signature R->>Q: enqueue raw body R-->>K: 200 OK (milliseconds) Q->>W: deliver W->>W: dedupe on event id, apply ``` ## Retries A delivery fails on a timeout, a connection error, or a status of `400` or higher. Kustomer retries a failed event up to **8 attempts in total, over about 64 minutes**: | Attempt | Delay before it | Time since the first attempt | | ------- | --------------- | ---------------------------- | | 1 | — | 0 | | 2 | 1 min | 1 min | | 3 | 1 min | 2 min | | 4 | 2 min | 4 min | | 5 | 5 min | 9 min | | 6 | 10 min | 19 min | | 7 | 15 min | 34 min | | 8 | 30 min | 64 min | Delays vary by about ±10% so retries from many events don't arrive in lockstep. If all 8 attempts fail, Kustomer drops that event. It isn't redelivered later, so plan to [reconcile](#reconcile-after-an-outage) after an outage. A delivery whose host name doesn't resolve (a DNS failure) isn't retried at all. > **Use status codes deliberately:** Return `2xx` for anything you've accepted, even if you'll ignore it. Return `5xx` only when you want a retry. A `4xx` is also retried, so don't use it to reject events you never want; acknowledge and drop them instead. _A timeline of up to 8 delivery attempts for one event, at 0, 1, 2, 4, 9, 19, 34 and 64 minutes, with the waits of 1, 1, 2, 5, 10, 15 and 30 minutes labelled between them and a to-scale strip showing the same attempts on a 70-minute clock. You pick how your endpoint responds: a 200 is delivered on the first attempt; a 500, a 401 or a timeout after 30 seconds fails every attempt, so the event is dropped after 64 minutes and isn't redelivered, and an endpoint down that long gets the webhook disabled; a 503 that recovers on a chosen attempt is delivered then; a 301 counts as delivered even though redirects aren't followed. Every attempt re-sends the same event id, and the latest transaction shows status failed with a nextRetry timestamp, or null once the event succeeds or runs out of attempts._ ## Duplicates and ordering Delivery is **at least once**. The same event can arrive twice, for example if your `200` was lost on the way back. Deduplicate on the envelope's `id`: ```ts title="Node.js" async function handle(event: KustomerEvent) { const firstTime = await redis.set(`kustomer:event:${event.id}`, '1', { NX: true, EX: 60 * 60 * 24 }) if (!firstTime) return await apply(event) } ``` Order isn't guaranteed either: a retried event can arrive after a newer one for the same record. Compare `data.attributes.updatedAt` with what you stored and skip older snapshots: ```sql title="Postgres" INSERT INTO conversations (id, status, updated_at, doc) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, updated_at = EXCLUDED.updated_at, doc = EXCLUDED.doc WHERE conversations.updated_at < EXCLUDED.updated_at; ``` ## Automatic disabling If your endpoint keeps failing, Kustomer turns the webhook off so it stops sending to a dead destination. This is based on failures **sustained over time**, not a short blip: a brief outage or a burst of errors on a busy webhook won't disable it, but an endpoint that stays down for tens of minutes will. Successful deliveries count in your favor. When a webhook is disabled: - `enabled` becomes `false` and `isError` becomes `true`. - Admins see an error notification in Kustomer. - Events that happen while it's off are not queued. Fix the endpoint, then re-enable it with [`PATCH /v1/outbound-webhooks/{id}`](https://developer.kustomer.com/api/apps-platform/update-outbound-webhook) and `{ "enabled": true }`, or with the toggle in **Settings › Platform › Outbound webhooks**. Re-enabling resets the failure history. ## Debug deliveries List the 25 most recent attempts for a webhook: `GET /v1/outbound-webhooks/{webhookId}/transactions` ([reference](https://developer.kustomer.com/api/apps-platform/list-transactions-for-webhook)) ```json title="200 OK" { "data": [ { "type": "outbound_webhook_transaction_log", "id": "5f2c62067aba570013e64bb1", "attributes": { "webhookId": "5f0f572d3919cc00123fd52e", "eventName": "kustomer.conversation.update", "sentAt": 1729522931273, "status": "failed", "nextRetry": "1729522991000" } } ] } ``` `status` is `succeeded` or `failed`. `nextRetry` is a millisecond timestamp for the next attempt, or `null` once the event succeeded or ran out of attempts. Common causes of failures: | Symptom | Likely cause | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Every delivery fails with `401` | Your receiver's signature check is hashing parsed JSON instead of the raw body | | Timeouts | Doing work before responding; move it to a queue | | `403` or no requests arrive | A firewall, WAF or bot protection is blocking Kustomer; allow the webhook path | | Deliveries show `succeeded` but never reach your code | The URL redirects (for example to add a trailing slash). Redirects aren't followed but count as delivered, so point the webhook at the final URL | | Some events never arrive | They exhausted their retries during an outage; reconcile | ## Reconcile after an outage Webhooks are for low latency, not completeness. After an outage longer than about an hour, or after re-enabling a webhook, fetch what changed with [search](https://developer.kustomer.com/docs/guides/search-recipes#incremental-sync) using an `updated_at` window that starts before the outage began. Because your handler [ignores older snapshots](#duplicates-and-ordering), overlapping with events you already processed is harmless. ## Network and security checklist - [x] HTTPS endpoint with a valid certificate - [x] Signature verified over the raw body, in constant time - [x] Token stored in a secret manager, not in code - [x] `200` returned within a few seconds; work done asynchronously - [x] Deduplication on event `id` - [x] Older snapshots ignored using `updatedAt` - [x] Alerting on non-2xx rates and on the webhook being disabled - [x] A reconciliation job for outages --- # Inbound webhooks > Send data into Kustomer from forms, servers and email with form hooks, web hooks and email hooks. Each hook gets a unique address and triggers workflows with the data you send. Source: https://developer.kustomer.com/docs/events/inbound-webhooks An inbound hook is a unique address that accepts data and turns it into an event. Nothing happens to that data until a [workflow](https://developer.kustomer.com/docs/workflows) with the hook as its trigger picks it up and decides what to create or update. ```mermaid flowchart LR F[HTML form] -- "POST, no auth" --> FH[Form hook] S[Your server or a partner] -- "POST + Bearer key" --> WH[Web hook] E[Order confirmation email] -- "Bcc" --> EH[Email hook] FH & WH & EH --> EV{{"kustomer.hooks.<type>.<id>.receive"}} EV --> WF[Workflow] WF --> R[(Customers, conversations,
custom objects…)] ``` | | Form hook | Web hook | Email hook | | -------------- | ---------------------------------------- | ------------------------------------------------------- | ---------------------------------------------- | | Address | `https://…/v1/hooks/form/{org}/{hash}` | `https://…/v1/hooks/web/{hash}` | `{hash}@hooks.kustomerapp.com` | | Authentication | None | `Authorization: Bearer ` with the `org.hooks` role | None (anyone who knows the address) | | Accepts | JSON, form data (including files), XML | JSON, form data (including files), XML | Email; JSON and JSON-LD in the HTML are parsed | | Best for | Public web forms | Server-to-server, partner integrations | Order and shipping emails you already send | | reCAPTCHA | Supported on the `{org}/{hash}` endpoint | — | — | > **Use the right hook for untrusted senders:** Form hooks and email hooks accept data from anyone who has the address. Treat everything they deliver as untrusted input: validate it in the workflow before creating or updating records. Use a web hook when you need to know the sender. ## Create a hook Create hooks in **Settings › Platform › Inbound webhooks**, or with the API: `POST /v1/hooks/web` ([reference](https://developer.kustomer.com/api/apps-platform/create-web-hook)) ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/hooks/web" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Shop orders", "description": "Order events from the storefront" }' ``` ```json title="200 OK" { "data": { "type": "web_hook", "id": "5825c452be3af71000ac6462", "attributes": { "description": "Order events from the storefront", "eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive", "hash": "7733e0aab921040f15aa203d89ef8809a015c33d5303bd3642972d59602e43a6", "url": "https://acme.api.kustomerapp.com/v1/hooks/web/7733e0aab921040f15aa203d89ef8809a015c33d5303bd3642972d59602e43a6", "debug": false } } } ``` Use the returned `url` (or `email`, for email hooks) exactly; don't build it yourself. `eventName` is the workflow trigger. The equivalent endpoints for the other types are [`POST /v1/hooks/form`](https://developer.kustomer.com/api/apps-platform/create-form-hook) and [`POST /v1/hooks/email`](https://developer.kustomer.com/api/apps-platform/create-email-hook). ## Send data **Web hook** ```bash curl -X POST "$HOOK_URL" \ -H "Authorization: Bearer $KUSTOMER_HOOK_KEY" \ -H "Content-Type: application/json" \ -d '{ "orderId": "4417", "email": "jane@example.com", "status": "shipped", "tracking": "1Z999AA10123456784" }' ``` Create a dedicated API key with only the `org.hooks` role for each sender. It can post to hooks and nothing else, so a leaked key can't read your data. Send it in the `Authorization` header; a key in the query string returns `401`. **Form hook** ```html
``` Prefer the `/v1/hooks/form/{org}/{hash}` form of the address over `/v1/hooks/form/{hash}`; it's the only one that supports reCAPTCHA. **Email hook** Add the hook address as a Bcc on emails you already send, such as order confirmations. Put structured data in the HTML as JSON-LD: ```html ``` The workflow receives the parsed email (sender, recipients, subject, text and HTML bodies, headers and attachments) along with any JSON found in the markup. Every hook answers with the ID of the transaction it created: ```json title="200 OK" { "data": { "id": "5825bf0fbe3af71000ac6427", "type": "transaction" } } ``` > **Limits:** Request bodies can be up to **400 KB**. Larger requests fail with `413`. Uploaded files must have a content type, or they're skipped. ## Use the data in a workflow Pick the hook as the workflow's trigger (**Trigger app › Hooks** in the builder, or its `eventName` in [workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json)). The trigger's output is the transaction, and what you sent is under `attributes.data`: ```json title="Step 1 output" { "id": "5825cb4af5fefd1100f9c7c9", "type": "transaction", "attributes": { "createdAt": "2026-10-21T15:20:42.554Z", "data": { "orderId": "4417", "email": "jane@example.com", "status": "shipped", "tracking": "1Z999AA10123456784" } } } ``` So the fields you posted are referenced as `/#steps.1.attributes.data.`: ```json title="Workflow step" { "id": "find-customer", "action": "kustomer.customer.find-by-email", "params": { "email": "/#steps.1.attributes.data.email" }, "transitions": [{ "target": "upsert-order", "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] } }] } ``` Files posted as form data become arrays of attachment descriptors (`_id`, `name`, `contentType`, `contentLength`) under the field name, ready to attach to a message or note. > **Build against a real payload:** Send one realistic request, open the workflow's logs, and copy step 1's output into the builder's **Example webhook data**. The builder then offers every field in its dropdowns. ## Debug a hook Turn on debugging to see exactly what Kustomer received: ```bash curl -X PATCH "https://acme.api.kustomerapp.com/v1/hooks/web/$HOOK_ID" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \ -d '{ "debug": true }' curl "https://acme.api.kustomerapp.com/v1/hooks/web/$HOOK_ID/transactions" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` Transactions received while debugging is on stay visible for **24 hours**. The same view is in **Settings › Platform › Inbound webhooks › View debug logs**. | Problem | Fix | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `401` from a web hook | Send the key as `Authorization: Bearer …`, and give it the `org.hooks` role | | `413` | Keep the body under 400 KB; send large files to [attachments](https://developer.kustomer.com/docs/guides/attachments) instead | | Transaction created but nothing happens | No enabled workflow uses this hook as its trigger, or its first condition didn't match. Check the workflow logs | | Fields are missing in the workflow | Reference them under `attributes.data`, and check the exact names in a debug transaction | ## Hooks in app definitions Apps can create hooks at install time, with their own event names, through the `hooks` section of an [app definition](https://developer.kustomer.com/docs/apps/app-definition). The app's workflows then use those event names as triggers. --- # Workflows > How Kustomer workflows run. A trigger event starts a run, steps call actions with params built from earlier outputs, and transitions with conditions choose the path. Covers the anatomy, run lifecycle, limits and API. Source: https://developer.kustomer.com/docs/workflows A workflow is a small program that runs inside Kustomer whenever an event happens. Admins build workflows in **Settings › Platform › Workflows** without code. Underneath, each workflow is a JSON document you can read, write and version through the [Workflows API](#manage-workflows-with-the-api). Use workflows for logic that should run next to your Kustomer data: tagging and routing conversations, keeping records in sync, calling your own services when something changes, and handling data from [inbound webhooks](https://developer.kustomer.com/docs/events/inbound-webhooks). New to workflows? Read these in order. Each one has an interactive scene you can step through. The rest of this section is reference material. _A workflow called sync-closed-conversations with five nodes: a conversation.update trigger, a customer.find action, a rest-api.json action, and two branches, append-tags when the status code is below 300 and note.create when it is 300 or above. Three scenarios play through it. In the closed scenario the trigger condition on changes.attributes.status.after equals done matches, the REST call returns 200 and the conversation is tagged. In the service down scenario the REST call returns 503, which does not fail the step, and the workflow branches to create a note. In the priority change scenario the trigger condition fails and the run ends after the trigger. A context panel shows each step's output stored under its ID._ ## Anatomy Every workflow has the same five building blocks. | Part | What it is | JSON | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | **Trigger** | The event that starts a run, such as `kustomer.conversation.update`. Its output, usually the record that changed plus a `changes` diff, is step `1` | `trigger.eventName` | | **Steps** | Actions to run, such as finding a customer, updating a conversation or calling a URL | `steps[].action` | | **Params** | Each step's inputs. Literal values, references to earlier outputs, or templates | `steps[].params` | | **Transitions** | Where to go after a step, each guarded by a condition | `transitions[].condition`, `.target` | | **Error cases** | What to do when a step fails: continue or stop | `steps[].errorCases` | Here's the workflow from the diagram above as JSON: ```json title="sync-closed-conversations.json" { "name": "sync-closed-conversations", "description": "Post closed conversations to our analytics service", "enabled": true, "trigger": { "id": "1", "eventName": "kustomer.conversation.update", "transitions": [ { "target": "lookup", "condition": { "op": "eq", "values": ["/#steps.1.changes.attributes.status.after", "done"] } } ] }, "steps": [ { "id": "lookup", "action": "kustomer.customer.find", "params": { "identifier": "/#steps.1.customer" }, "transitions": [{ "target": "post", "condition": { "op": "true", "values": [true] } }] }, { "id": "post", "action": "kustomer.rest-api.json", "params": { "method": "post", "uri": "https://api.example.com/kustomer/closed", "headers": { "Content-Type": "application/json", "Authorization": "Bearer {{{variables.6513b2f4a4b1e1001a6b3f20}}}" }, "json": true, "data": { "conversationId": "/#steps.1.id", "email": "/#steps.lookup.emails.0.email", "closedAt": "/#steps.1.lastDone.createdAt" } }, "transitions": [ { "target": "tag", "condition": { "op": "lt", "values": ["/#steps.post.response.statusCode", 300] } }, { "target": "note", "condition": { "op": "gte", "values": ["/#steps.post.response.statusCode", 300] } } ] }, { "id": "tag", "action": "kustomer.conversation.append-tags", "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000001"] }, "transitions": [] }, { "id": "note", "action": "kustomer.note.create", "params": { "conversation": "/#steps.1.id", "body": "Sync failed: HTTP {{steps.post.response.statusCode}}" }, "transitions": [] } ] } ``` The `Authorization` header reads a [workflow variable](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#variables-and-secrets). `6513b2f4a4b1e1001a6b3f20` is the variable's ID, not its name: workflows always refer to variables by ID. Three rules explain most of what you see: 1. **Outputs are keyed by step ID.** When a step finishes, its output is stored at `steps.` in the run's context. The trigger is always step `1`. Steps you create in the builder get short random IDs such as `SJsVbyiGf`. 2. **References keep their type; templates make strings.** A param that is exactly `"/#steps.1.id"` resolves to the value itself, whether that's a string, number, array or object. Any other string is rendered as a Handlebars template, such as `"Sync failed: HTTP {{steps.post.response.statusCode}}"`, and the result is always a string. See [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions). 3. **The first matching transition wins.** After a step runs, its transitions are checked in order. The first one whose condition passes picks the next step. If none match, the run ends. That's normal, and it's how most runs end: the trigger fires on every update, and the first condition filters out the ones you don't care about. Runs only move forward. A transition can't point back to a step that's already on the path to it, and Kustomer rejects such a workflow when you save it. To repeat work later, schedule a [callable workflow](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows). For the full engine model, see [How workflows run](https://developer.kustomer.com/docs/workflows/how-workflows-run). ## The life of a run ```mermaid sequenceDiagram autonumber participant K as Kustomer participant W as Workflow engine participant A as Actions K->>W: Event (kustomer.conversation.update) W->>W: Find enabled workflows with this trigger Note over W: Each matching workflow runs in turn W->>W: Normalize event into step 1 output loop Until no transition matches W->>W: Check transitions in order W->>A: Run next step with resolved params A-->>W: Output stored at steps. end W-->>K: Run complete (or error recorded) ``` **Runtime behavior** > **Avoid loops:** A workflow that updates the record that triggered it fires the trigger again. Guard every update trigger with a condition on `changes` (what changed in _this_ update), not on the current state. For example, check `/#steps.1.changes.attributes.status.after` rather than `/#steps.1.status`. ## Where workflow events come from | Trigger family | Event names | Learn more | | -------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Records created or updated | `kustomer.customer.create`, `kustomer.conversation.update`, `kustomer.kobject..create` and others | [Triggers and actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions#built-in-triggers) | | Inbound webhooks | `kustomer.hooks.web..receive` and the form and email equivalents | [Inbound webhooks](https://developer.kustomer.com/docs/events/inbound-webhooks) | | Other workflows | `kustomer.workflow..call` | [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows) | | Apps | Event names an app defines, such as `kustomer.app..` | [Apps: workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers) | ## Manage workflows with the API Workflows are ordinary resources. Read them to back them up, diff them in code review, or copy them between orgs. | Operation | Endpoint | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | List workflows (filter by `enabled`, `eventName`, `callable`) | [`GET /v1/workflows`](https://developer.kustomer.com/api/workflows/get-workflows) | | Create a workflow | [`POST /v1/workflows`](https://developer.kustomer.com/api/workflows/create-a-workflow) | | Get a workflow | [`GET /v1/workflows/{id}`](https://developer.kustomer.com/api/workflows/get-a-workflow-based-on-the-id) | | Update a workflow (only the root keys you send) | [`PUT /v1/workflows/{id}`](https://developer.kustomer.com/api/workflows/update-a-workflow-based-on-the-id) | | List, create, update and delete variables | [`/v1/workflows/{id}/variables`](https://developer.kustomer.com/api/workflows/get-all-variables-for-a-workflow) | | List failed runs | [`GET /v1/workflows/{id}/processes`](https://developer.kustomer.com/api/workflows/get-processes-errors-by-workflow) | ```bash title="cURL" curl "https://acme.api.kustomerapp.com/v1/workflows?eventName=kustomer.conversation.update&enabled=true" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" ``` ```bash title="kusty" kusty api GET /v1/workflows --query eventName=kustomer.conversation.update --query enabled=true --output json ``` > **For agents editing workflows:** `PUT /v1/workflows/{id}` updates only the root keys you send, and each one replaces the stored value wholesale. Fetch the workflow, change the JSON, and send `trigger` and `steps` together, because steps and transitions depend on each other. Keep step IDs stable, because every `/#steps.` reference depends on them, and refer to workflow variables by their ID. Run `kusty api PUT … --dry-run` first to see the request without sending it. The full schema is in [Workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json), and a worked example is in [Write workflow JSON](https://developer.kustomer.com/docs/workflows/write-workflow-json). ## Next --- # How workflows run > The workflow engine's mental model, in depth. How an event picks the workflows that run, what a step is, how its output becomes /#steps., how transitions choose the next step, and what happens on timeouts, failures, not-found lookups, server errors, loops and callable workflows. Source: https://developer.kustomer.com/docs/workflows/how-workflows-run A workflow run is simple once you can picture it. An event happens. Kustomer finds the enabled workflows whose trigger matches that event. Each one starts a **run**: the event becomes the output of step `1`, and the run walks from step to step. Every step adds its output to a shared **context**, and its **transitions** decide which step comes next. When no transition matches, the run is over. This page follows that path in detail, from the event to the last step, including what happens when things go wrong. For the parts of a workflow at a glance, see the [overview](https://developer.kustomer.com/docs/workflows). For the syntax used inside steps, see [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables). ```mermaid flowchart TD E["Event, such as kustomer.conversation.update"] --> M["Each enabled workflow with that eventName,
one at a time"] M --> T["Step 1 output = the trigger's output"] T --> C{"Check transitions
in order"} C -->|"first match"| S["Run the target step
and store its output at steps.<id>"] S -->|"then check its transitions"| C C -->|"no match"| N(["Run ends"]) ``` ## From event to run ### Which workflows run Every workflow has one trigger. An event trigger names the event in `trigger.eventName`, such as `kustomer.conversation.update`. When that event happens, every **enabled** workflow with that event name gets a chance to run. - **Several workflows can match one event.** They run one after another, not in parallel. Don't rely on the order, and don't build a workflow that depends on running before or after another one for the same event. - **A workflow can stand in for another.** If an enabled workflow's [`replaces`](https://developer.kustomer.com/docs/workflows/workflow-json#override-an-app-workflow-with-replaces) points at a second workflow, the second one is skipped for that event. - **Edits take about a minute to reach new runs.** Kustomer caches workflows, so a workflow you just created, enabled or saved may not match events for up to a minute. - **Busy orgs can skip events.** Workflow events are rate limited per org and event name. Events over the limit are skipped, not queued. Calls to callable workflows aren't rate limited. Runs for _different_ events are independent. They can run at the same time and finish in any order. ### The trigger is step 1 The trigger always has the ID `"1"`, and its output is the first thing in the run's context. What that output contains depends on the kind of trigger: | Trigger | Step `1` output | Example reference | | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | A record event, such as `kustomer.conversation.update` | The record, **flattened**: attributes at the top level, relationships as plain IDs, and for updates, a `changes` diff | `/#steps.1.status`, `/#steps.1.changes.attributes.status.after` | | An [inbound web hook](https://developer.kustomer.com/docs/events/inbound-webhooks) | The hook's payload, with your fields under `attributes.data` | `/#steps.1.attributes.data.email` | | A [callable workflow](#callable-workflows-and-responses) | **Only** the caller's inputs, cast to the trigger's `schema` | `/#steps.1.conversationId` | The full shape of trigger output is in [Triggers and actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions#what-the-trigger-outputs). Paths and how they resolve are covered in [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables#what-you-can-address). The trigger has transitions like any other step, and they act as the workflow's **trigger condition**. Most runs end right here: an update trigger fires on every change to the record, and the trigger's first condition filters out the changes you don't care about. A run that ends after the trigger is normal, not an error. ## Anatomy of a step A step is one object in the workflow's `steps` array. Here's a real one, a customer lookup, both as the builder shows it and as JSON. Hover or tap each part, then press **Run** to watch it execute against three different API answers. _A lookup step shown two ways. On the left, the workflow builder's card: badge 2 and the title Look up the customer, Action App Kustomer and Action Event Customer: Lookup (By ID), a required identifier field set to the trigger's Customer, a condition node named Customer is VIP leading to step 3, and a dashed note that the classic builder has no error-case editor. On the right, the same step as JSON with id lookup, action kustomer.customer.find, appVersion, params.identifier set to /#steps.1.customer, one transition to tag-vip when /#steps.lookup.custom.vipBool equals true, one error case that stops processing when /#error.status is 400, and meta.displayName. Selecting a part highlights it on both sides and explains it. Run plays the step: params resolve, the action runs, the output is stored as steps.lookup, and transitions are checked. With a 200 the VIP branch matches, with a 404 the output is empty and no transition matches so the run ends successfully, and with a 400 the step fails, /#error is set and the error case stops the run quietly._ | Key | Required | What it does | | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Yes | Names the step. Its output is stored as `steps.`, and transitions use it as their `target`. Use readable IDs without dots when you write JSON, such as `lookup` | | `action` | No | The action to run, such as `kustomer.customer.find`. A step without `action` is a **Condition Step**: it only evaluates its transitions | | `params` | No | The action's inputs: literals, [references and templates](https://developer.kustomer.com/docs/workflows/data-and-variables) | | `transitions` | Yes | Where to go next, checked in order. `[]` makes this a final step | | `errorCases` | No | What to do when the action fails | | `appVersion` | No | The app and version range of the action, such as `kustomer-^1.2.1`. The builder adds it when you save | | `meta` | No | Labels for the builder. `displayName` is the step's title | The [Workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json#steps) reference has every key's limits and patterns. > **Badge numbers aren't step IDs:** The builder numbers steps 1, 2, 3 by their position on the canvas. In the JSON, each step has a generated short ID, such as `SAk4K54Ly`, and references use that ID: `/#steps.SAk4K54Ly.id`. Hover a step's badge and choose **Copy Step ID** to get it. Only the trigger's ID is always `1`. ## What happens in each step #### Resolve the params Each param is resolved against everything the run has so far: earlier steps' outputs, workflow variables and app settings. A string that is exactly one reference, such as `"/#steps.1.customer"`, becomes that value with its type. Any other string is rendered as a template and becomes a string. Then each param is converted to the type the action's input expects. #### Drop params that resolved to nothing A reference that resolves to `null` or to nothing at all removes the param, so it isn't sent. An empty object is treated as not set, too. The one exception is a `null` queue, which is kept and means "unassign". #### Run the action The action runs with the resolved params. A Condition Step has no action, so it skips this stage. #### Store the output On success, the action's output is stored in the context as `steps.`. Kustomer actions return the record, flattened in the same way as trigger output. #### Check the transitions The step's transitions are checked in order, and the first one whose condition passes picks the next step. If none pass, the run ends successfully. > **Always supply required inputs:** A Kustomer action with a missing or empty required input may fail or return empty output. Empty values count as missing, including `0`, `false` and `""`. If a required param comes from a reference, guard the step with an `exists` condition on that value. ### Outputs become /#steps.\ Once a step has run, any later step can read its output by the step's ID. Here's the lookup step from the scene above and a note step that uses its output: ```json title="Two steps: note reads the output of lookup" { "steps": [ { "id": "lookup", "action": "kustomer.customer.find", "params": { "identifier": "/#steps.1.customer" }, "transitions": [ { "target": "note", "condition": { "op": "exists", "values": ["/#steps.lookup.id"] }, "meta": { "name": "Customer found" } } ] }, { "id": "note", "action": "kustomer.note.create", "params": { "conversation": "/#steps.1.id", "body": "Customer: {{{steps.lookup.name}}}, {{{steps.lookup.emails.0.email}}}" }, "transitions": [] } ] } ``` After `lookup` runs, the context holds its output under `steps.lookup`: ```json title="steps.lookup (abridged)" { "id": "6512ef18a4b1e1001a6b2c11", "name": "Jane Rivera", "emails": [ { "email": "jane@example.com" }, { "email": "jane.rivera@example.com" } ], "custom": { "vipBool": true } } ``` So `/#steps.lookup.id` is the customer's ID, `/#steps.lookup.emails` is the whole array, and the note's body renders as `Customer: Jane Rivera, jane@example.com`. A step can only read outputs of steps that already ran on this path. A reference to a step that didn't run, or to a field that isn't there, resolves to nothing rather than causing an error. ### How transitions choose the next step After a step runs, the engine looks at its `transitions` from first to last: 1. It evaluates each transition's `condition` in order. 2. The **first** one that passes wins, and the run moves to its `target`. Later transitions aren't checked. 3. If **none** pass, the run ends. That's a successful run, not a failure. That makes order meaningful. Put the most specific condition first and the broadest last. In the builder, sibling condition nodes are checked **left to right**, and you reorder them with **Move left** and **Move right**. ```json title="Most specific first, catch-all last" { "id": "route", "transitions": [ { "target": "urgent", "condition": { "op": "and", "values": [ { "op": "gte", "values": ["/#steps.1.priority", 4] }, { "op": "eq", "values": ["/#steps.lookup.custom.vipBool", true] } ] }, "meta": { "name": "Urgent VIP" } }, { "target": "vip", "condition": { "op": "eq", "values": ["/#steps.lookup.custom.vipBool", true] }, "meta": { "name": "VIP" } }, { "target": "everyone-else", "condition": { "op": "true", "values": [true] }, "meta": { "name": "Always" } } ] } ``` `route` has no `action`, so it's a Condition Step. A VIP conversation with priority 4 matches all three conditions, but only `urgent` runs. If `vip` came first, `urgent` would never run. - `{ "op": "true", "values": [true] }` always passes. When it's a step's only transition, the builder draws a plain line instead of a condition node. - A step can have up to 10 transitions, and no two can be identical. - A `target` that doesn't match any step fails the run with `next step not found`. An operator the engine doesn't support fails it with `operation not supported`. The operators and their casting rules are in [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#operators). ## The run context Everything a step can read lives in one object, the run's **context**. References such as `/#steps.lookup.id` are paths into it. ```json title="The context partway through a run (abridged)" { "steps": { "1": { "id": "6512f0c2a4b1e1001a6b2c90", "status": "done", "customer": "6512ef18a4b1e1001a6b2c11" }, "lookup": { "id": "6512ef18a4b1e1001a6b2c11", "name": "Jane Rivera", "custom": { "vipBool": true } } }, "variables": { "6513b2e4a4b1e1001a6b3f10": "tok_9f2c41" } } ``` - `steps` grows by one entry per step that succeeds. A step that failed and was continued past by an error case adds nothing. - `variables` holds the workflow's [variables](https://developer.kustomer.com/docs/workflows/data-and-variables#workflow-variables), keyed by ID. They're loaded only when the workflow refers to them. - `error` appears only while a failed step's error cases are checked. See [Error cases](#error-cases). When logging is on, Kustomer records each step's input, output, success and timing, plus the workflow version that ran. Secrets that Kustomer adds are removed from the logged input. Failed-run records store the same context with each output under `steps..output`. See [Debugging](https://developer.kustomer.com/docs/workflows/debugging). ## Time limits A run has **5 minutes**. The limit is checked **between** steps, so a single slow step can run past it, and the run ends when that step finishes. A REST step waits up to 60 seconds for a response. For anything slower, reply quickly and finish the work asynchronously, as described in [Keep it fast](https://developer.kustomer.com/docs/workflows/code-steps#keep-it-fast). ## When a step fails How a step fails decides what happens next. Kustomer actions follow the HTTP status of the API call they make: | What happened | Result | | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | A Kustomer action returns `2xx` | Success. The output is stored | | A Kustomer action returns `404` | **Success with empty output** | | A Kustomer action returns another `4xx` | The step fails. The error is at `/#error`, and the step's error cases apply | | A Kustomer action returns `5xx`, or there's an unexpected system error | Error cases are skipped. The run **starts again from step 1**. Nothing is recorded in the workflow's errors | | A REST step gets any HTTP status | Success. Branch on `/#steps..response.statusCode` | | A REST step gets no response (DNS failure, connection refused or reset, timeout) | The step fails | | A bad template, a missing action or trigger, or an unsupported operator | The run fails with that message | | The run passes 5 minutes | The run ends after the current step | ```mermaid flowchart TD A["Kustomer action responds"] --> B{"HTTP status"} B -->|"2xx"| OK["Success:
output stored"] B -->|"404"| EMPTY["Success:
empty output"] B -->|"other 4xx"| F["Step fails:
the error is set"] B -->|"5xx"| R(["Error cases skipped:
run restarts from step 1"]) F --> EC{"errorCases,
checked in order"} EC -->|"continue"| CONT["No output stored:
step's transitions checked"] EC -->|"stop-processing"| STOP(["Run ends quietly"]) EC -->|"no case matches"| FAIL(["Run fails and is
recorded as an error"]) ``` ### Error cases Error cases tell the engine what to do when a step fails. Each case has a `condition` and an `errorAction`. The cases are checked in order, and the first match applies. While they're checked, the error is available at `/#error`. For a Kustomer action, that's the first item of the API's `errors` array, with fields such as `status`, `code`, `title`, `detail` and `source`. ```json title="Continue past a validation error, stop quietly on anything else" { "id": "create-customer", "action": "kustomer.customer.create", "params": { "name": "/#steps.1.attributes.data.name", "email": "/#steps.1.attributes.data.email" }, "errorCases": [ { "condition": { "op": "or", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] }, "errorAction": { "type": "continue" } }, { "condition": { "op": "and", "values": [] }, "errorAction": { "type": "stop-processing" } } ], "transitions": [ { "target": "record-order", "condition": { "op": "exists", "values": ["/#steps.create-customer.id"] }, "meta": { "name": "Customer created" } }, { "target": "notify-team", "condition": { "op": "true", "values": [true] }, "meta": { "name": "Not created" } } ] } ``` | `errorAction.type` | Effect | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `continue` | The step stores **no output**, and the run moves on to the step's transitions. Branch on whether the output exists, as above | | `stop-processing` | The run ends quietly. It isn't recorded as a failure | | `retry-workflow` | Behaves exactly like `continue`. Nothing is retried | | `retry-action` | Isn't handled, so the step fails as if no case matched | - A case whose `condition.values` is empty matches every error. Put it last. - An error case's `condition.op` must be `and` or `or`. Each case can hold up to five conditions, and a step can have up to 10 cases. - Read the error at `/#error.status`, `/#error.code` and so on. It's never at `/#steps..error`. - If no case matches, the run stops and the failure is recorded on the workflow. See [Debugging](https://developer.kustomer.com/docs/workflows/debugging). > **Add error cases in JSON:** The classic workflow builder has no editor for error cases. Add `errorCases` with **Edit workflow JSON** in the builder's options menu, or with [`PUT /v1/workflows/{id}`](https://developer.kustomer.com/api/workflows/update-a-workflow-based-on-the-id). ### Not found isn't a failure A Kustomer action that gets a `404` succeeds with empty output. A lookup that finds nothing isn't an error, so error cases don't see it. Branch on the result instead: ```json title="Branch after every lookup" { "id": "find", "action": "kustomer.customer.find-by-email", "params": { "email": "/#steps.1.attributes.data.email" }, "transitions": [ { "target": "update-customer", "condition": { "op": "exists", "values": ["/#steps.find.id"] }, "meta": { "name": "Customer found" } }, { "target": "create-customer", "condition": { "op": "dne", "values": ["/#steps.find.id"] }, "meta": { "name": "No customer yet" } } ] } ``` Without the second transition, a run that finds no customer simply ends. ### Server errors restart the run When a Kustomer action gets a `5xx` response, or the platform hits an unexpected error, the step's error cases are skipped. Kustomer retries the run, and the retry **starts again from step 1**. Steps that already succeeded run again. These retries aren't recorded in the workflow's errors. So design every step before a risky one to be **safe to repeat**: - Prefer lookups and additive changes, such as `append-tags`, early in the workflow. Adding the same tag twice changes nothing. - Before creating a record, look it up first, so a second run finds the record from the first run instead of creating a duplicate. Storing your system's ID as an external ID and using a `find-by-external-id` lookup is a reliable way to do this. - Be careful with steps that have side effects outside Kustomer, such as REST calls. Use an idempotency key, such as the conversation ID, so your service can ignore a repeat. [Make steps safe to repeat](https://developer.kustomer.com/docs/workflows/patterns#make-steps-safe-to-repeat) shows these techniques as complete workflows. ## Workflows can't loop A transition can only move forward. When you save a workflow, Kustomer rejects any transition whose target is a step already on the path to it, with a `400` error: ```text Step with ID transitions to a previous step with ID : ``` The builder shows the same problem on the canvas as "Cycle detected: _target_ already visited". To repeat work later, such as checking a conversation again after an hour, have the workflow schedule a [callable workflow](#callable-workflows-and-responses) with the Scheduler app's **Schedule a Workflow** action. The callable workflow runs later with fresh data. See [Schedule follow-ups instead of looping](https://developer.kustomer.com/docs/workflows/patterns#schedule-follow-ups-instead-of-looping) for a worked example. > **A workflow can still trigger itself:** The save-time check only covers transitions inside one workflow. A workflow that updates the record that triggered it fires its own trigger again. Guard update triggers with a condition on `changes`, as described in [Workflows](https://developer.kustomer.com/docs/workflows#the-life-of-a-run). ## Callable workflows and responses A **callable** workflow has no event trigger. Its trigger declares the inputs it accepts, and something else starts it, usually the Scheduler app's **Schedule a Workflow** action in another workflow. ```mermaid sequenceDiagram participant A as Workflow A participant S as Scheduler app participant B as Callable workflow B A->>S: Schedule a Workflow (workflowId, runAt, inputs) Note over S: Waits until runAt S->>B: Start a run with the inputs B->>B: Step 1 output = inputs, cast to trigger.schema B->>B: Re-fetch the record, then act ``` - **Step 1 is only the inputs.** The caller's `inputs` are cast to `trigger.schema` and become step `1`'s output. Nothing else is added, so read an input as `/#steps.1.conversationId`, never `/#steps.1.inputs.conversationId`. - **Kustomer sets the event name.** Don't set `eventName` yourself. Kustomer sets it to `kustomer.workflow..call`. - **Time passes.** By the time a scheduled workflow runs, the record may have changed. Look it up again before you act on it. - **Scheduling has its own checks.** `runAt.type` must be `absolute` (an ISO 8601 timestamp) or `relative` (whole seconds), or the step fails with "runAt type must be one of absolute or relative" or "invalid runAt value". Inputs that are too large fail with "Payload too large to schedule workflow". A callable workflow can also send a result back with **Workflow: Send Response** (`kustomer.workflow.respond`), which takes `outputs`. That only matters when the caller waits for a result, for example an AI agent that uses the workflow as a tool. The caller receives: | The run… | The caller gets | | --------------------------------- | ---------------------------------- | | Reaches a Send Response step | Success, with the `outputs` | | Ends without a Send Response step | No response | | Fails | A workflow error or a system error | Declare the outputs on the builder's **Output Parameters** tab, which saves them as `response.schema`. A complete callable workflow, with its trigger schema and a respond step, is in [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows). ## What's checked when you save The API validates every workflow when you create or update it, so some mistakes never reach a run: | Rule | Detail | | ----------------- | ----------------------------------------------------------------------------------------------------- | | No loops | A transition can't target a step already on its path | | Transitions | Up to 10 per step, and no two identical | | Error cases | Up to 10 per step, each with up to 5 conditions. `condition.op` must be `and` or `or` | | Targets | Can't contain dots | | Steps | Only `id` and `transitions` are required. `action` is optional | | Callable triggers | Need `id`, `callable`, `schema` and `transitions` | | App settings | An invalid `{{{ setting … }}}` reference is rejected with "Invalid app settings configuration passed" | | Names | Stored lowercase and trimmed, and unique in your org. A duplicate name returns `409` | | App workflows | Can't be edited (`409`), but you can still turn them on or off and toggle logging | Everything else, such as a reference to a field that doesn't exist, only shows up at run time. Check the [logs](https://developer.kustomer.com/docs/workflows/debugging) after every change. ## Next --- # Data and variables > The language of workflow data. Covers /#steps path references and {{{ }}} Handlebars templates side by side, what each produces and when to use it, every path you can address (trigger output, changes, step outputs, variables by ID, /#error, callable inputs), path syntax, missing values, type conversion, common helpers, escaping, and how builder picks translate to JSON. Source: https://developer.kustomer.com/docs/workflows/data-and-variables Every value in a workflow is one of three things: a **literal** such as `"done"` or `4`, a **path reference** such as `"/#steps.1.status"`, or a **template** such as `"Order {{{steps.1.custom.orderNumberStr}}}"`. References and templates read from the same place, the run's [context](https://developer.kustomer.com/docs/workflows/how-workflows-run#the-run-context), but they behave differently. Knowing which one to use, and what each one produces, is most of what you need to write workflows confidently. This page is the grammar. For condition operators, see [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions). For every function and helper, see the [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference). ## Two syntaxes | | Path reference | Handlebars template | | --------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- | | Looks like | `"/#steps.1.tags"` | `"{{{steps.1.tags}}}"`, `"Order {{{steps.1.id}}}"` | | Recognized when | The **whole** string starts with `/#` | Any other string. Each `{{ }}` tag in it is replaced | | Produces | The value **with its type**: string, number, boolean, array or object | Always a **string** | | Missing value | Nothing, so a param is left out | An empty string | | Mixed with text | No | Yes | | Helpers | Functions, written `/#fn:,,…` | Handlebars helpers, such as `{{{toLower steps.1.subject}}}` | | In the builder | Written for you when you pick a field | Typed with **Toggle Code View** | Both forms write the path the same way, `steps.1.tags`; only the wrapping differs. Here they are in one step: ```json title="A reference and a template in the same step" { "id": "note", "action": "kustomer.note.create", "params": { "conversation": "/#steps.1.id", "body": "Order {{{steps.1.custom.orderNumberStr}}}: {{{steps.find.name}}}" }, "transitions": [] } ``` `conversation` needs an ID, so it's a reference to one. `body` mixes text and values, so it's a template. ### A reference is the whole value A string is a reference only when `/#` is the very first thing in it. There's no way to embed a reference in other text: use a template. | Value | Result | | -------------------------- | -------------------------------------------------------------------------- | | `"/#steps.1.id"` | `"6512f0c2a4b1e1001a6b2c90"`, the conversation's ID | | `"Order /#steps.1.id"` | `"Order /#steps.1.id"`, unchanged. Here `/#` is just text | | `"Order {{{steps.1.id}}}"` | `"Order 6512f0c2a4b1e1001a6b2c90"` | | `"steps.1.id"` | `"steps.1.id"`, a plain string. Without `/#` or braces nothing is resolved | ### Templates produce strings A template is rendered to text, whatever the values inside it are. Each param is then converted to the type the action's input expects, so a template that renders `"4"` for a number input becomes `4`. That conversion only goes so far, so pick the syntax by the type you need: | You need | Use | Why | | ------------------------------------------------------ | ----------------------------------------------------------------- | -------------------------------------------------- | | A value as is: an ID, number, boolean, array or object | A reference: `"/#steps.1.tags"` | It keeps its type. Arrays and objects arrive whole | | Text built from several values | A template: `"Order {{{steps.1.custom.orderNumberStr}}} shipped"` | Only templates can mix text and values | | A value converted to another type | A function: `"/#fn:parseFloat,steps.1.attributes.data.total"` | Functions return typed values | Arrays and objects don't survive templates. A template prints an array's items joined with commas, so `{{{steps.1.tags}}}` renders `"64f1c0ffee5ca1ab1e000001,64f1c0ffee5ca1ab1e000002"`, and it prints an object as `[object Object]`. To put an object into text, such as a log message, use `{{{toJSON steps.find.custom}}}`. ### Double or triple braces `{{ }}` HTML-escapes the value. `{{{ }}}` inserts it unchanged. | Template | Renders | | ---------------------- | ---------------------------------- | | `"{{steps.1.name}}"` | `"Order 4417 hasn't arrived"` | | `"{{{steps.1.name}}}"` | `"Order 4417 hasn't arrived"` | Notes, URLs, headers and JSON bodies all need the original text, so use triple braces everywhere unless you're building HTML and want the escaping. ## Try it Pick a value, type your own, or click a line of the context. Each one is resolved three ways, as a reference and as both kinds of template, so you can see how the type changes. _A sample run context in JSON: step 1 is a conversation update trigger with id, name, status, priority, two tag IDs, a customer ID, a custom orderNumberStr and a changes diff where status went from open to done; find is a customer lookup with id, name, one email and custom.vipBool true; variables holds one value under the 24-character variable ID 6513b2e4a4b1e1001a6b3f10; and error holds status 400, present only during error cases. A text box and preset chips choose a value. The scene highlights the path it walks in the context and shows the result three ways. For example, /#steps.1.tags resolves to the array itself while {{{steps.1.tags}}} renders one comma-joined string; /#steps.1.tags.length is the number 2; /#steps.find.custom is an object but renders as \[object Object] in a template; {{steps.1.name}} escapes the apostrophe. Gotchas show that /#variables.analyticsToken, a name instead of an ID, and /#steps.find.phones.0.phone resolve to nothing so the param is left out, that Order /#steps.1.id is plain text, and that steps.1.status without /# is just a string._ ## What you can address A path starts at the top of the run's context: | Path | What it is | Available | | ------------------------ | ---------------------------------------- | ------------------------------------------- | | `steps.1.` | The trigger's output | Always | | `steps..` | A step's output, by the step's `id` | After that step succeeds on this run's path | | `variables.` | A workflow variable, by its ID | Always, when the workflow has the variable | | `error.` | The error from the step that just failed | Only in that step's `errorCases` | ### Trigger output: steps.1 A record trigger's output is the record, flattened: its attributes sit at the top level, relationships are plain IDs, and update triggers add a `changes` diff. The full example is in [What the trigger outputs](https://developer.kustomer.com/docs/workflows/triggers-and-actions#what-the-trigger-outputs). | Reference | Value | | --------------------------------- | -------------------------------------------------------- | | `/#steps.1.id` | The record's ID | | `/#steps.1.status` | The conversation's current status, such as `"done"` | | `/#steps.1.customer` | The customer's **ID**. Look the customer up to read more | | `/#steps.1.tags` | An array of tag IDs | | `/#steps.1.custom.orderNumberStr` | A custom attribute | Other triggers put their data in different places: - **Inbound web hooks** put your payload under `attributes.data`, so a field called `email` is `/#steps.1.attributes.data.email`. Paste a sample payload into the trigger's **Example Webhook Data** so its fields appear in the builder's picker. - **Callable workflows** get only their inputs, cast to the trigger's `schema`. An input called `conversationId` is `/#steps.1.conversationId`, not `/#steps.1.inputs.conversationId`. ### What changed: changes Update triggers include a `changes` diff with each changed attribute's `before` and `after`. Custom attributes change as one `custom` entry, so the field name comes **after** `before` or `after`: | Reference | Value | | --------------------------------------------------- | ---------------------------------------------------- | | `/#steps.1.changes.attributes.status.after` | The new status, set only when this update changed it | | `/#steps.1.changes.attributes.status.before` | The previous status | | `/#steps.1.changes.attributes.custom.after.tierStr` | The new value of the custom attribute `tierStr` | | `/#steps.1.changes.attributes.tags.after` | The full tag list after the change | Gate update triggers on `changes` rather than on the current value. See [Conditions on what changed](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#conditions-on-what-changed). ### Step outputs: steps.\ | Step | Output | Example references | | -------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------- | | A Kustomer action | The record it found or wrote, flattened like trigger output | `/#steps.find.id`, `/#steps.find.emails.0.email` | | A Kustomer lookup that finds nothing | Empty, so every path under it resolves to nothing | `exists [/#steps.find.id]` is false | | A step continued past by an error case | Nothing | Same as above | | A REST step | `response.statusCode`, `response.headers` (lowercase names) and `body` | `/#steps.call.response.statusCode`, `/#steps.call.body.eligible` | | A regex step | `match`, `captures`, `index` and `original` | `/#steps.order-number.captures.0` | A step's output is exactly what the builder's **View Logs** shows for it. When a value comes out empty, compare your path with the logged output. See [Debugging](https://developer.kustomer.com/docs/workflows/debugging). ### Workflow variables Workflow variables hold secrets and settings that shouldn't be written into the workflow, such as an API token. **Reference a variable by its ID, not its name.** The ID is the 24-character `data.id` returned when you [create the variable](https://developer.kustomer.com/api/workflows/create-a-workflow-variable-for-a-workflow). ```json title="Right: by ID" { "headers": { "Authorization": "Bearer {{{variables.6513b2e4a4b1e1001a6b3f10}}}", "X-Api-Key": "/#variables.6513b2e4a4b1e1001a6b3f11" } } ``` ```json title="Wrong: by name" { "headers": { "Authorization": "Bearer {{{variables.analyticsToken}}}" } } ``` The name is only a label. A reference by name resolves to nothing, so the header above would be sent as `"Bearer "`. You rarely need to type IDs yourself: - In the builder, picking a variable from a field's picker inserts `/#variables.`. - In an object field, such as a REST step's headers, the **Variables** button copies `{{{variables.}}}` for you to paste. - With the API, [list the workflow's variables](https://developer.kustomer.com/api/workflows/get-all-variables-for-a-workflow) to find each one's ID. Values are strings of 1 to 1,024 characters. The builder and the API show them masked, with only the last 6 characters visible, so they can't be read back. Each variable belongs to one workflow. If you copy a workflow's JSON into another workflow or another org, every variable reference breaks until you create the variables there and update the IDs. Kustomer loads variables only when the workflow refers to them, so always write `/#variables.` or `{{{variables.` out in full. ### The error: /#error Inside a step's [error cases](https://developer.kustomer.com/docs/workflows/how-workflows-run#error-cases), `/#error` is the error that step just raised. For a Kustomer action, it's the first item of the API's `errors` array: | Reference | Value | | ---------------- | -------------------------------------------------- | | `/#error.status` | The HTTP status, such as `400` | | `/#error.code` | The error's code | | `/#error.title` | A short summary | | `/#error.detail` | A longer explanation | | `/#error.source` | Which part of the request was at fault, when given | `/#error` exists only while error cases are checked. It's never at `/#steps..error`, and it's gone once the run moves on. ### App settings Workflows that ship in an [app](https://developer.kustomer.com/docs/apps/settings) read the installing org's settings with the `setting` helper, in templates only: `{{{ setting .. }}}`. It can also take a default and a type: `{{{ setting .. }}}`. See [App settings](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#app-settings). ## Path syntax | Path | Means | | ------------------------------------- | --------------------------------------------- | | `steps.find.emails.0.email` | The first email. A number picks an array item | | `steps.find.emails[0].email` | The same, with a bracket index | | `steps.1.tags.length` | How many tags, as a number | | `steps.1.name.length` | How many characters in the name | | `steps.1.attributes.data["order.id"]` | A key that contains dots, in quoted brackets | - Indexes are whole numbers from `0`. There are no negative indexes, so there's no "last item" shortcut. - Only the object's own keys are read. - In templates, use dots for indexes: `{{{steps.find.emails.0.email}}}`. - Function arguments are bare paths, without `/#`: `/#fn:parseInt,steps.1.custom.qtyStr`, not `/#fn:parseInt,/#steps.1.custom.qtyStr`. - Give steps IDs without dots, such as `find-customer`. Transition targets can't contain dots. ## Missing values A path that doesn't resolve never causes an error. What you get instead depends on where the path is used: | Where | A missing value becomes | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | A param set to a reference | Nothing: the param is left out of the request. The one exception is a `null` queue, which is kept and means "unassign" | | A param set to an empty object | Nothing, the same way | | Inside a template | An empty string | | A condition operand | `""`. So `exists` fails, `dne` passes, `neq` against a value passes, and `eq` against a value fails | | A required input of a Kustomer action | The action may fail or return empty output. `0`, `false` and `""` count as missing too | A typo in a step ID or field name looks exactly like missing data. To handle values that really can be missing: - **Guard with a condition.** Put `exists [/#steps.find.id]` on the transition into the step that needs it. - **Give templates a fallback.** `{{{or steps.find.firstName 'there'}}}` prints the first name, or `there` when there isn't one. - **Leave optional params out.** `/#fn:ifExists,steps.find.id,/#steps.find.name` resolves to the name only when the lookup found someone. ## Types and conversion After a param is resolved, it's converted to the type of the action's input: | Param | Resolves to | Sent to a number input as | | --------------------------------------- | ----------- | ------------------------- | | `"/#steps.1.priority"` | `3` | `3` | | `"{{{steps.1.priority}}}"` | `"3"` | `3` | | `"/#fn:parseInt,steps.1.custom.qtyStr"` | `12` | `12` | Use functions when the source has the wrong type, such as a number stored as text in a web hook payload: | Function | Returns | When the input isn't valid | | ------------------------ | ----------------------------- | -------------------------- | | `/#fn:parseInt,` | An integer | `0` | | `/#fn:parseFloat,` | A number | `0` | | `/#fn:parseBool,` | `true` for `true` or `"true"` | `false` | | `/#fn:parseJSON,` | The parsed object | `{}` | An unknown function name doesn't fail. It returns the input unchanged, so check the spelling against the [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference#functions). Conditions do their own casting: `eq` converts the right side to the left side's type. See [Operators](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#operators). ## Handlebars helpers you'll use most Helpers go inside templates, with positional arguments first and then `key=value` options. Arguments are paths, single-quoted strings or numbers. | Helper | Example | Renders | | ------------ | -------------------------------------------------------------------- | ------------------------------------ | | `toLower` | `{{{toLower steps.1.subject}}}` | `where is my refund?` | | `or` | `{{{or steps.find.firstName 'there'}}}` | The first name, or `there` | | `dateFormat` | `{{{dateFormat steps.1.createdAt format='YYYY-MM-DD'}}}` | `2026-10-21` | | `date` | `{{{date 'days' count=-3}}}` | An ISO timestamp 3 days **from now** | | `uriEncode` | `{{{uriEncode steps.1.attributes.data.email}}}` | `jane%40example.com` | | `toJSON` | `{{{toJSON steps.find.custom}}}` | `{"vipBool":true}` | | `ifCond` | `{{#ifCond steps.1.priority '>=' 4}}Urgent{{else}}Normal{{/ifCond}}` | `Urgent` or `Normal` | | `#if` | `{{#if steps.find.custom.vipBool}}VIP{{/if}}` | `VIP`, or nothing | Conditions accept templates as well as references, which is how you make a text check case-insensitive: ```json { "op": "contains", "values": ["{{{toLower steps.1.subject}}}", "refund"] } ``` > **date counts backwards:** `{{{date 'hours' count=24}}}` is 24 hours **ago**. For a time in the future, such as a Scheduler `runAt`, use a negative count: `{{{date 'hours' count=-24}}}`. Or skip the helper and schedule with `"runAt": { "type": "relative", "value": 86400 }`. The full list, with every option and default, is in the [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference#handlebars-helpers). ## Escaping Three layers can change your text: JSON, Handlebars and URLs. - **JSON.** A workflow is a JSON document, so backslashes and double quotes in strings need escaping: a regex `\d+` is written `"\\d+"`. Helper arguments use single quotes, `format='LL'`, so they don't need escaping. - **HTML.** `{{ }}` HTML-escapes values. Use `{{{ }}}`. - **URLs.** A value that goes into a URL needs encoding: `"https://api.example.com/customers?email={{{uriEncode steps.1.attributes.data.email}}}"`. Otherwise a space or `&` breaks the request. To send JSON to your own service, give the REST step's `data` an object, and use references for the values. Each value keeps its type, and the step serializes the body for you. Don't build a JSON body as a template string. ```json title="Build bodies from references, not strings" { "data": { "conversationId": "/#steps.1.id", "priority": "/#steps.1.priority", "tags": "/#steps.1.tags", "summary": "{{{steps.find.name}}}: {{{steps.1.name}}}" } } ``` ## From the builder to JSON The builder writes all of this for you. Knowing what it writes lets you read any workflow's JSON, and fix it when the picker can't express what you need. | In the builder | In the JSON | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Pick a field from an earlier step | `"/#steps.."` as the whole value. The field shows the step's number and the field's name, such as "Custom > VIP" | | Pick a field from the trigger | `"/#steps.1."` | | Pick a variable | `"/#variables."` | | **Variables** button on an object field | Copies `{{{variables.}}}` to paste into the JSON | | Pick a function | `"/#fn:,,…"` | | Type text with `{{ }}` in **Toggle Code View** | A template string, such as `"Order {{steps.1.custom.orderNumberStr}} shipped"` | | Type `vip,priority` into an array field | `["vip", "priority"]` | | Type `15` into a number field | `15` | | Type `true` into a boolean field | `true` | | Type `[]` into the tags field | `[]`, which clears all tags | | Leave the queue field empty | Unassigns the queue | | Clear an optional field | The param is removed | | The badge on a step | Not in the JSON. The step's `id` is a generated short ID, such as `SAk4K54Ly` | | **Example Webhook Data** | Not in the JSON. It's saved only in your browser, and lists the payload's fields in the picker as `/#steps.1.attributes.data.` | Code view text is committed when you press Enter or click away, and it's converted to the field's type at that point. > **References in array fields:** Typing a reference into an **array** field's code view wraps it in a list: `/#steps.1.tags` becomes `["/#steps.1.tags"]`. To pass a whole array from an earlier step, open **Edit workflow JSON** and set the param to the bare string `"/#steps.1.tags"`. ## Next --- # Build a workflow in the builder > A hands-on tutorial in Kustomer's visual workflow builder. Build a workflow that tags conversations from VIP customers, and see the JSON each builder action writes. Covers the workflows list, the canvas, triggers, the data picker, conditions, variables, testing, logs, saving and enabling, and what the builder can't do. Source: https://developer.kustomer.com/docs/workflows/build-in-the-builder The workflow builder is a visual editor for the same JSON document you can read and write with the [Workflows API](https://developer.kustomer.com/docs/workflows/workflow-json). Every node you add and every field you fill writes a specific piece of that JSON. Once you can see the JSON behind each click, the builder stops being mysterious, and you can switch between the two whenever it's convenient. In this tutorial you'll build one workflow from start to finish: **when a conversation is created, look up its customer, and if the customer is a VIP, tag the conversation**. After each builder action, you'll see the JSON it produces. _A stylized mock of the classic workflow builder that builds the Tag VIP conversations workflow one action at a time, next to a JSON so far panel. The steps are: create the workflow (name tag-vip-conversations, displayName Tag VIP conversations, enabled false); choose the Kustomer Conversation Created trigger (eventName kustomer.conversation.create); add an action step, which gets a generated ID and a plain always line from the trigger ({ op: true, values: \[true] }); pick Customer: Lookup (By ID) (action kustomer.customer.find); fill the identifier field from the data picker, which inserts /#steps.1.customer; add a condition branch named Customer is VIP that continues only if step 2's Custom > VIP equals true; add a Conversation: Add Tags step with id /#steps.1.id and the VIP tag's ID; save, which creates a new version and adds appVersion to each step; and switch the workflow on, which sends only enabled: true. Prev, next and replay buttons step through the actions._ This page is about the builder. If you'd rather write the JSON yourself, follow [Write a workflow in JSON](https://developer.kustomer.com/docs/workflows/write-workflow-json), which writes a find-or-create workflow for an order web hook one key at a time and creates it through the API. For the ideas underneath both, read [How workflows run](https://developer.kustomer.com/docs/workflows/how-workflows-run) and [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables). ## Before you start You need: - Access to **Settings › Platform › Workflows** in your Kustomer org. - A Boolean [custom attribute](https://developer.kustomer.com/docs/concepts/custom-attributes) on customers that marks VIPs. This tutorial calls it **VIP**, stored as `custom.vipBool`. Use your own field if you have one. - A tag for VIP conversations, and its ID. Workflows store tags as IDs, not names. If a conversation already has the tag, you can see its ID in that conversation's `tags` array, for example in a workflow's logs. - A test customer with the VIP field turned on, so you can check the result. > **Which builder this page describes:** The labels on this page are from the classic workflow builder. If your org uses a newer version of the builder, some labels and layouts differ, but it saves the same workflow JSON. ## Find your way around ### The workflows list **Settings › Platform › Workflows** lists every workflow in your org. | Part | What's there | | -------------------- | -------------------------------------------------------------------------------------------------------- | | Filters | **User Created**, **App Created**, **All Triggers**, **Scheduled Workflows**, **Callable Workflows** | | Columns | **Name**, **Errors**, **Updated**, **Actions**. Each workflow is either **Activated** or **Deactivated** | | Row actions | **Duplicate**, **Export JSON**, **View Error Logs** | | **Template Gallery** | Prebuilt workflows. Choose **Use This Workflow** to start from one | Workflows installed by an app show "App workflows cannot be edited". You can still turn them on and off. ### The canvas The canvas draws the workflow as a tree, from the trigger at the top down to the last steps. | On the canvas | What it is | In the JSON | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | **Trigger** node, badge **1** | The event that starts a run. Shows "Choose Trigger" until you pick one | `trigger`, always `"id": "1"` | | **Action** node, numbered badge | A step that runs an action. Shows "Choose Action" in red until it has an action and no errors | An entry in `steps[]` with an `action` | | Condition node | A condition on the path to the next step. Shows its name, or "Edit Condition" when it's unnamed | A transition with a `condition` and `meta.name` | | Plain line | A path that's always taken | A step's only transition, `{ "op": "true", "values": [true] }` | | **+** below a node | Adds what comes next: **Action**, **Condition Step**, **Condition Branch**, or **Paste** after you've copied a step or condition | New `steps[]` and `transitions[]` entries | > **Badge numbers aren't step IDs:** The canvas numbers steps 1, 2, 3… by their position in the tree. In the JSON, every step except the trigger has a generated short ID, such as `B1gTq2Lxk`, and references use that ID: `/#steps.B1gTq2Lxk.custom.vipBool`. Hover over a node's badge and choose **Copy Step ID** to get it. Only the trigger is always `1`. ## Create the workflow and pick a trigger #### Create a custom workflow On the workflows list, select **Add Workflow**, then **Custom Workflow** ("Build your workflow from a clean slate"). In **Create New Workflow**, fill in: - **Workflow Title**: `Tag VIP conversations` - **Workflow Description**: `Adds the VIP tag to new conversations from customers whose VIP field is true.` Leave the **Callable Workflow** toggle off, and select **Create**. The title allows letters, numbers, spaces, `-`, `_` and `.`. ```json title="What Create writes" { "name": "tag-vip-conversations", "description": "Adds the VIP tag to new conversations from customers whose VIP field is true.", "meta": { "displayName": "Tag VIP conversations" } } ``` The title becomes two things: `name`, lowercased with spaces turned into `-`, and `meta.displayName`, exactly as you typed it. `name` must be unique in your org. New workflows start with `enabled` set to `false`, so nothing runs until you switch the workflow on. #### Choose the trigger Select the **Choose Trigger** node. In the side panel: - Type a **Trigger Title**, such as `New conversation`. - Under **Trigger Information**, set **Trigger App** to **Kustomer** and **Trigger Event** to **Conversation Created**. **Details** describes the event. ```json title="trigger" { "id": "1", "eventName": "kustomer.conversation.create", "meta": { "displayName": "New conversation" }, "transitions": [] } ``` The trigger's output is step `1`: the new conversation, flattened, so its ID is `/#steps.1.id` and its customer's ID is `/#steps.1.customer`. [Triggers and actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions#built-in-triggers) lists every built-in trigger and its builder name. ## Add a step and fill its params #### Add an action Select **+** below the trigger and choose **Action**. A new node appears with badge **2**, connected by a plain line. ```json title="trigger.transitions and steps" { "trigger": { "id": "1", "eventName": "kustomer.conversation.create", "transitions": [{ "target": "B1gTq2Lxk", "condition": { "op": "true", "values": [true] } }] }, "steps": [{ "id": "B1gTq2Lxk", "transitions": [] }] } ``` The plain line is a transition whose condition always passes. The new step gets a generated ID; yours will be different from `B1gTq2Lxk`. #### Pick the action Select the new node. In the side panel, type an **Action Title**, `Look up the customer`. Under **Action Information**, set **Action App** to **Kustomer** and **Action Event** to **Customer: Lookup (By ID)**. ```json title="steps[0]" { "id": "B1gTq2Lxk", "action": "kustomer.customer.find", "meta": { "displayName": "Look up the customer" }, "params": {}, "transitions": [] } ``` The conversation only holds the customer's ID. To read the customer's VIP field, you look the customer up first. #### Fill the identifier with the data picker **Required fields** now shows a count of 1: `identifier`. Select the field. A searchable picker opens, listing the earlier steps by number and name with their output fields. Under **1**, choose **Customer**. ```json title="steps[0].params" { "identifier": "/#steps.1.customer" } ``` The picker sets the **whole field** to one reference, `/#steps..`. The field shows the step number and the field's name rather than the raw path. ### How the data picker works The picker is the easiest way to get paths right, because it builds them from each step's real output. It lists: - **Earlier steps**, by number and name, with their output fields. Choosing one writes `/#steps..`. A nested field shows as a trail, such as **Custom > VIP**. - **Variables**. Choosing one writes `/#variables.`. See [Use workflow variables](#use-workflow-variables). - **Functions**, the `/#fn:` helpers, each with "Returns:" and "Example:". See the [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference#functions). - **Value lists** that depend on the field, such as **Queues**, **Boolean Values** (True and False), **System Klasses** and **System Methods**, and pickers for users, teams, statuses and priorities. Optional inputs live under **Optional fields**, which shows how many you've used out of the total. Use **Add field** ("Search fields...") to add one or several, and **Remove field** to take one off. Removing a field deletes its key from `params`. #### Mix text and values in code view The picker can't combine text with a value. For that, turn on **Toggle Code View** (the `` icon) and type a [template](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#templates): ```text Order {{{steps.1.custom.orderNumberStr}}} needs a VIP follow-up ``` What you type is saved when you press `Enter` or click away, and it's converted to the field's type first. The JSON can differ from what you typed: | Field type | You type | Saved as | | ---------- | ---------------- | -------------------------------------------- | | Array | `vip,priority` | `["vip", "priority"]` (split on commas) | | Number | `15` | `15` | | Boolean | `true` | `true` | | Array | `/#steps.1.tags` | `["/#steps.1.tags"]` (see the warning below) | | Any | (empty) | The param is removed | For a **tags** field, `[]` means "clear all tags". For a **queue** field, an empty value means "unassign". > **References in array fields get wrapped:** A reference typed into an **array** field's code view is wrapped in a list, so `/#steps.1.tags` is saved as `["/#steps.1.tags"]`. To pass a whole array from an earlier step, set the param to the bare string `"/#steps.1.tags"` in **Edit workflow JSON**. A field is highlighted when what you typed doesn't match its type. Values that start with `/#`, and values that are a whole `{{…}}` template, are always accepted. **Object** fields, the ones that take JSON, open a JSON editor instead, and invalid JSON blocks saving with "Fix invalid JSON in action parameters before saving". ## Add a condition So far the workflow looks up every new conversation's customer. Now add the check: continue only if that customer is a VIP. In the builder, a condition sits on the path between two steps. In the JSON, it's the `condition` of a transition. #### Add a condition branch Select **+** below **Look up the customer** and choose **Condition Branch**. A condition node labelled "Edit Condition" appears. It's shown in an error style until it has a complete rule and a step to go to. #### Name it and set the rule Select the condition node. Type a name in the title field: `Customer is VIP`. Then, under **Continue Only If**: 1. **Select field**: under **2**, choose **Custom > VIP**. 2. Operator: **Equals**. A Boolean field only offers **Equals** and **Not equal**. 3. **Select Value**: `true`. ```json title="steps[0].transitions[0] (no target yet)" { "condition": { "op": "eq", "values": ["/#steps.B1gTq2Lxk.custom.vipBool", "true"] }, "meta": { "name": "Customer is VIP" } } ``` The field is on step 2's output, so the path uses step 2's generated ID, not the number 2. The builder saves values other than numbers as you typed them, so `true` is stored as the string `"true"`. That works: `eq` casts the right side to the left side's type, so `"true"` becomes the boolean `true`. If a customer has no VIP value at all, the condition doesn't match. #### Add the tag step Select **+** below the condition and choose **Action**. Title it `Add the VIP tag`, and set **Action App** to **Kustomer** and **Action Event** to **Conversation: Add Tags**. Fill the two required fields: - `id`: from the picker, under **1**, choose **ID**. That's the conversation's ID. - `tags`: turn on **Toggle Code View** and paste the VIP tag's ID. ```json title="steps[0].transitions" { "transitions": [ { "target": "rkM8xV0Zp", "condition": { "op": "eq", "values": ["/#steps.B1gTq2Lxk.custom.vipBool", "true"] }, "meta": { "name": "Customer is VIP" } } ] } ``` ```json title="steps[1]" { "id": "rkM8xV0Zp", "action": "kustomer.conversation.append-tags", "meta": { "displayName": "Add the VIP tag" }, "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000002"] }, "transitions": [] } ``` The condition now has a `target`. **Add Tags** adds the tag without touching the conversation's other tags. The new step has no transitions, so the run ends after it. ### The condition editor Every condition in the builder has the same parts: **Select field**, an operator, and **Select Value**. Use **Toggle Code View** to type a raw path or a template instead of picking a field. Try it here: build a condition on the tutorial's data and test it against three sample conversations. _An interactive condition editor modeled on the builder's Continue Only If panel. You pick a field from step 1 (Conversation Created: Name, Channels, Priority, Created At) or step 2 (Look up the customer: Custom > VIP), an operator offered for that field's type using the builder's labels (Exists, Does not exist, Equals, Not equal, Greater than, Less than, Before, After, Contains, Does not contain and so on), and a value. Add Condition turns the rule into a Multiple Conditions (And) group, which you can switch to Or. The condition JSON updates live, and three sample conversations, a VIP email, a normal chat and an email from a customer with no VIP value, each show match or no match with the reason: for example, eq casts the string true to a boolean, contains on text is case-sensitive, and Not equal passes when the field is missing._ The operator list depends on the field's type. Each label maps to one `op` in the JSON, and [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#operators) explains exactly when each passes. | Label | `op` | Label | `op` | | ------------------------- | -------- | ------------------------ | -------------- | | Exists | `exists` | Less than | `lt` | | Does not exist | `dne` | Less than or equal | `lte` | | Equals | `eq` | Before | `lt-date` | | Not equal | `neq` | After | `gt-date` | | Greater than | `gt` | Contains | `contains` | | Greater than or equal | `gte` | Does not contain | `not-contains` | | Multiple Conditions (And) | `and` | Multiple Conditions (Or) | `or` | | Field type | Operators offered | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Text | Exists, Does not exist, Equals, Not equal, Contains, Does not contain | | Date (date-time text) | Exists, Does not exist, Equals, Not equal, Before, After | | Number | Exists, Does not exist, Equals, Not equal, Greater than, Greater than or equal, Less than, Less than or equal | | Boolean | Equals, Not equal | | List | Exists, Does not exist, Equals, Not equal, Contains, Does not contain | | Unknown, or a path typed in code view | All twelve comparisons | The builder never offers the `true` and `false` operators. It writes `true` for you when it draws a plain line. Number values use a number input, so they're saved as numbers. ### Combine rules with and and or Select **Add Condition** to turn a single rule into a **Multiple Conditions (And)** group that holds the existing rule plus a new, empty one. Change the group to **Multiple Conditions (Or)** with its operator dropdown. Groups can hold other groups. ```json title="VIP and arrived by email" { "op": "and", "values": [ { "op": "eq", "values": ["/#steps.B1gTq2Lxk.custom.vipBool", "true"] }, { "op": "contains", "values": ["/#steps.1.channels", "email"] } ] } ``` There's no "is one of" operator. To test "priority is 4 or 5", use an **Or** group of two **Equals** rules. > **Case-insensitive text checks:** **Contains** on text is case-sensitive, so `"Refund for order 4417"` doesn't contain `refund`. Turn on **Toggle Code View** for the field and type a template that lowercases it, such as `{{{toLower steps.1.name}}}`, then compare it with a lowercase value. ### Branches and their order A step can have several condition branches side by side, up to 10. **Their left-to-right order is their priority**: after the step runs, the branches are checked from left to right, and the first one that matches wins. Use the **Move left** and **Move right** arrows on a condition to reorder it. In the JSON, the order is the order of the step's `transitions` array. If no branch matches, the run ends there. That's a normal, successful end, not an error. Put the most specific branch first, because a broad branch on the left swallows everything it matches. ### Condition steps Sometimes you want to branch without running an action first. Choose **Condition Step** from the **+** menu: it's a step with no action, and its condition branches do the routing. For example, in a workflow triggered by **Message Created**, a step that sends emails about refunds one way and emails about shipping another: ```json title="A Condition Step with two branches" { "id": "Hy3pQe7Lm", "meta": { "displayName": "Route by keyword" }, "transitions": [ { "target": "SkT0f9cQa", "meta": { "name": "Mentions refund" }, "condition": { "op": "contains", "values": ["{{{toLower steps.1.subject}}}", "refund"] } }, { "target": "rJ5vWn2Xb", "meta": { "name": "Mentions shipping" }, "condition": { "op": "contains", "values": ["{{{toLower steps.1.subject}}}", "shipping"] } } ] } ``` A subject that mentions both words only takes the left branch. To act on both, chain the checks instead: refund check, refund tag, shipping check, shipping tag. The [patterns](https://developer.kustomer.com/docs/workflows/patterns) page has more shapes like this. ## Save, test and enable #### Save Select **Save** and confirm: "Are you sure you wish to save this workflow? This will create a new version." Save sends the workflow's `steps`, `trigger` and `response`, and adds `appVersion` to each step, recording the app and version range of its action: ```json title="A saved step" { "id": "B1gTq2Lxk", "action": "kustomer.customer.find", "appVersion": "kustomer-^1.2.1", "meta": { "displayName": "Look up the customer" }, "params": { "identifier": "/#steps.1.customer" }, "transitions": [ … ] } ``` Your version range may differ. Save is disabled, with a tooltip, when there's nothing to save ("No unsaved changes"), when an object field has invalid JSON, or for an app's workflow. #### Check the JSON Open the options menu and choose **Edit workflow JSON**. The editor shows `name`, `description`, `trigger` and `steps` exactly as Kustomer stores them. Compare it with [the finished workflow](#the-finished-workflow) below. The editor shows the **last saved version**, so save your canvas changes first. **Update** saves the JSON straight to the workflow, and Kustomer validates it. Malformed JSON shows "Invalid JSON.". #### Try a dry run If your options menu has **Test workflow**, use it before you switch the workflow on. It's a dry run in your browser: **it doesn't run actions or change data**. 1. Edit the **Sample Event Data**, or select **Regenerate from Workflow** to build it from the fields the workflow uses. 2. Select **Run test**. The **Execution Flow** shows how many steps ran, such as "3 steps executed", each step's **Input data** and **Output data**, and which branch was taken ("Next step") or skipped ("Transition not matched"). 3. Because actions don't run, edit the lookup step's output to set `custom.vipBool` to `true`, then choose **Run from Here** to check the VIP branch. #### Switch it on Turn on the switch in the header. It takes effect immediately, and it's separate from Save: it changes only `enabled`. ```json title="What the switch sends" { "enabled": true } ``` You can't switch on a workflow while any condition lacks a target: "All conditions in active workflows must point to a target". Allow about a minute before the workflow starts matching new events. #### Watch a real run Open **View Logs** and turn **Debug Mode** on. Runs are recorded for the next hour, and the log shows the **TOP 100 LOG EVENTS**. Then create a conversation for your VIP test customer. Each entry, such as "Log Event 1", says when the run completed and how many seconds it took, and names its **Last Step**. Open it to see **All Logged Steps For Event**, with every step's input and output. A run whose last step is the lookup means the VIP condition didn't match: check `custom.vipBool` in the lookup's output. Use [Debugging and logs](https://developer.kustomer.com/docs/workflows/debugging) for anything else. Failed runs appear under **View errors** in the options menu, in **Workflow Errors**, as "Failed with Error at step …". Select **Clear All Errors** once you've fixed the cause. Temporary platform errors that Kustomer retries aren't listed there. ### The rest of the header | Control | What it does | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Undo** and **Redo** | Each names the change it will undo or redo | | Version picker | **Latest version**, then earlier versions by date. An old version opens read-only. Kustomer keeps the 30 most recent versions | | **Edit info** | Opens **Edit Workflow Info**, with **Workflow Title** and **Workflow Description**. It saves at once, and **renaming the title also changes `name`**, so look workflows up by ID in code | | **Workflow variables** | Opens the variables modal. See below | | **View hook logs** | For workflows triggered by a hook, what the hook received | | **Delete workflow** | "Deleting this workflow is immediate." | ## The finished workflow This is what **Edit workflow JSON** shows after you save. Your step IDs, tag ID and `appVersion` ranges will differ. ```json title="tag-vip-conversations" { "name": "tag-vip-conversations", "description": "Adds the VIP tag to new conversations from customers whose VIP field is true.", "trigger": { "id": "1", "eventName": "kustomer.conversation.create", "meta": { "displayName": "New conversation" }, "transitions": [ { "target": "B1gTq2Lxk", "condition": { "op": "true", "values": [true] } } ] }, "steps": [ { "id": "B1gTq2Lxk", "action": "kustomer.customer.find", "appVersion": "kustomer-^1.2.1", "meta": { "displayName": "Look up the customer" }, "params": { "identifier": "/#steps.1.customer" }, "transitions": [ { "target": "rkM8xV0Zp", "condition": { "op": "eq", "values": ["/#steps.B1gTq2Lxk.custom.vipBool", "true"] }, "meta": { "name": "Customer is VIP" } } ] }, { "id": "rkM8xV0Zp", "action": "kustomer.conversation.append-tags", "appVersion": "kustomer-^1.2.1", "meta": { "displayName": "Add the VIP tag" }, "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000002"] }, "transitions": [] } ] } ``` When you write JSON by hand, you can choose readable step IDs, such as `find-customer`, instead of generated ones. [Write a workflow in JSON](https://developer.kustomer.com/docs/workflows/write-workflow-json) shows how. ## Use workflow variables Variables hold values you don't want written into the workflow itself, such as API keys and signing secrets. Each variable belongs to one workflow. 1. Open the options menu and choose **Workflow variables** ("Create variables to be used within this workflow."). Names can't contain spaces. 2. Fill in **Name** and **Value** and select **Save** on that row. Each row saves on its own, separately from the workflow's **Save** button. 3. Saved values are masked: you see `XXXXXXXXXXXXXXXXXXXXXXXX` followed by the last 6 characters. To rename a variable, select **Edit** and leave the value blank. Deleting a variable that's in use stops the workflow from working properly. To use a variable in a field, choose it from the picker's **Variables** section. In object fields, the **Variables** button copies a ready-made template instead. Both reference the variable by its **ID**, not its name: ```json title="Variables in params" { "uri": "/#variables.6531c0ffee5ca1ab1e0000a1", "headers": { "Authorization": "Bearer {{{variables.6531c0ffee5ca1ab1e0000a1}}}" } } ``` The ID is the variable's 24-character ID. Because variables belong to one workflow, copying a workflow's JSON to another workflow or org breaks every variable reference until you create the variables there and update the IDs. [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables#workflow-variables) covers variables in depth. ## Other kinds of trigger ### Hooks Set **Trigger App** to **Hooks** to start a workflow from an [inbound webhook](https://developer.kustomer.com/docs/events/inbound-webhooks), an email hook or a form hook. **Add New Hook** opens **Add Kustomer Hook**, with **Title of Hook** and **Select Type**: **Email Hook**, **Web Hook** or **Form Hook**. **Hook Information** then shows the **Hook Address** that starts the hook, the **Hook Key**, and **Example Webhook Data**. Paste a sample payload into **Example Webhook Data**. The builder reads field types from it and lists the fields in the data picker, so you can pick them instead of typing `/#steps.1.attributes.data.`. The sample is only saved in your browser ("This data will only be saved locally"). ### Callable workflows Turn on **Callable Workflow** in **Create New Workflow** to make a workflow that other workflows run, usually later, with the Scheduler app. The toggle only appears when the Scheduler app is installed. The builder starts the trigger as: ```json { "id": "1", "callable": true, "schema": {}, "transitions": [] } ``` and opens **Edit Input Parameters** ("Data fields expected by this workflow"). On the **Input Parameters** tab, select **Add Parameter** for each input and give it a name, a type (**String**, **Number** or **Array**) and, if needed, **Required Field**. The **Output Parameters** tab declares what the workflow sends back. **Update** writes `trigger.schema` and `response.schema` as `{ "properties": { "": { "type": "…" } }, "required": [ … ] }` in your draft. Save the workflow to keep them. In a callable workflow, step `1` is just the inputs, so read them as `/#steps.1.`. To run it, add the Scheduler app's action to another workflow: pick the callable workflow, fill its inputs, and choose **Select a Time Type**, then **Set an Exact Date and Time** or **Set a Relative Time**. See [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows). ## What the builder can't do > **There's no editor for error cases:** The classic builder doesn't show or edit `errorCases`. To decide what happens when a step fails, add them in **Edit workflow JSON** or with the API. ```json title="Added in Edit workflow JSON: stop quietly if the tag can't be added" { "id": "rkM8xV0Zp", "action": "kustomer.conversation.append-tags", "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000002"] }, "errorCases": [ { "condition": { "op": "and", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] }, "errorAction": { "type": "stop-processing" } } ], "transitions": [] } ``` The error is at `/#error`, never at `/#steps..error`. Error cases don't apply to a `5xx` from a Kustomer action: Kustomer retries the run from step 1, so earlier steps run again. See [Handle errors](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#handle-errors). The builder also follows the rules of the workflow JSON: - **No loops.** A transition can't go back to a step that's already on its path. The canvas warns "⚠️ Cycle detected: … already visited", and saving is rejected. To repeat work later, schedule a callable workflow. - **One end per branch.** Give each branch its own final step, even if two branches end with the same action. - **Unique branches.** Two identical transitions on one step are rejected. ### Export JSON, Duplicate and Import **Export JSON** and **Duplicate** copy `name`, `description`, `meta`, `steps` and `trigger`, and nothing else: - The exported name is `export---`, with the display name prefixed "Export". A duplicate's name starts with `copy-`, with the display name prefixed "Copy". - `enabled` isn't copied, so copies start switched off. Neither is `logging`. - A callable workflow's output parameters (`response`) are dropped. Add them again with **Edit Input Parameters**. - Variables aren't copied, and their IDs belong to the original workflow. Create the variables on the copy and update every reference. To load a file, select **Add Workflow**, then **Import Workflow** ("Import workflow with a JSON file"), choose a `.json` file and select **Import**. The file is created as is, so its `name` must be valid and unique in the org. When you copy a workflow to another org, also replace org-specific IDs such as tags, teams and users; see [Workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json#create-and-update-with-the-api). ### Deleting steps Deleting a step opens **Delete Step** ("What would you like to delete?") with two choices: **Delete this step only**, which reconnects the steps below it to its parent, or **Delete this step and all steps below**. A step with several branches has to be deleted with everything below it. A condition can't be deleted while steps hang off it. ## Builder vocabulary ↔ JSON | In the builder | In the JSON | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Workflow Title | `name` (lowercased, spaces become `-`) and `meta.displayName` (as typed) | | Workflow Description | `description`, up to 4,096 characters | | The on/off switch | `enabled`. New workflows start `false` | | **Debug Mode** in View Logs | `logging: true`, which records runs for one hour | | Trigger Title | `trigger.meta.displayName` | | Trigger App and Trigger Event | `trigger.eventName`, such as `kustomer.conversation.create` | | Callable Workflow toggle | `trigger: { "id": "1", "callable": true, "schema": { … }, "transitions": [ … ] }`. Kustomer sets `eventName` to `kustomer.workflow..call` | | Input Parameters tab | `trigger.schema.properties..type` and `trigger.schema.required` | | Output Parameters tab | `response.schema.properties` and `response.schema.required` | | Badge **1** | The trigger, `id: "1"`. Its output is `/#steps.1…` | | Action node | A `steps[]` entry: `id`, `action`, `params`, `transitions`, `appVersion`, `meta.displayName` | | Action Title | The step's `meta.displayName` | | Action App and Action Event | `action`, such as `kustomer.conversation.append-tags`, plus `appVersion`, such as `kustomer-^1.2.1` | | Required and optional fields | `params.`. Removing an optional field deletes the key | | Picking a step's field | `"/#steps.."`, as the whole value | | Picking a variable | `"/#variables."` | | Picking a function | `"/#fn:,,…"` | | Code view text with `{{…}}` | A Handlebars template string | | A condition | A transition: `{ "target", "condition": { "op", "values": [left, right] }, "meta": { "name" } }` | | Multiple Conditions (And) or (Or) | `"condition": { "op": "and" \| "or", "values": [ { "op", "values" }, … ] }` | | Condition Branch | Another entry in the same step's `transitions`, in left-to-right order | | Condition Step | A step with no `action`, whose `transitions` do the branching | | A plain line between two nodes | The step's only transition, `{ "op": "true", "values": [true] }` | | **Delete this step only** | The parent's transition is re-pointed to the deleted step's child | ## Next --- # Write a workflow in JSON > A hands-on tutorial that builds a find-or-create workflow as JSON, one key at a time, then creates, tests, enables and updates it with the Workflows API and kusty. Covers root keys, triggers, steps and params, transitions, error cases, variables by ID, partial updates, and the validation errors you'll meet. Source: https://developer.kustomer.com/docs/workflows/write-workflow-json Every workflow is a JSON document. The builder writes that document for you, but you can also write it yourself, keep it in source control, review changes in pull requests, and ship it with one API call. This tutorial builds a real workflow that way, starting from an empty skeleton. You'll build **Record shipped orders**. Your store posts to a Kustomer [web hook](https://developer.kustomer.com/docs/events/inbound-webhooks) each time an order ships. The workflow looks up the customer by email, creates the customer if there isn't one, and records the order as a custom object on that customer. Before you start, you need: - An API key with the `org.admin.workflow.write` role (or `org.permission.workflow.create` and `org.permission.workflow.update`) and the matching read role. See [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles). - A web hook for the store. [Create one](https://developer.kustomer.com/docs/events/inbound-webhooks#create-a-hook) and keep its `eventName`. - Optionally, [kusty](https://developer.kustomer.com/docs/ai/kusty), the Kustomer CLI. Every request below is shown with cURL and kusty. If you haven't read how a run moves through a workflow, start with [How workflows run](https://developer.kustomer.com/docs/workflows/how-workflows-run) and [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables). To build the same kind of workflow by clicking instead of typing, see [Build in the builder](https://developer.kustomer.com/docs/workflows/build-in-the-builder). _An interactive stepper that grows the record-shipped-orders workflow JSON through six stages, with the lines each stage adds highlighted and a flow diagram that gains the same steps. Skeleton: only name, description, enabled false, meta.displayName and an empty steps array. Trigger: a web hook trigger with id 1 and no transitions. First step: a find-customer step using kustomer.customer.find-by-email, reached from the trigger when the payload has both an email and an order number. Lookup: a record-order step that reads /#steps.find-customer.id, reached when that ID exists. Branches: a second transition, taken when the ID does not exist, leads to create-customer and then to its own record-order-new-customer step. Error case: create-customer gets an error case that stops the run quietly when /#error.status is 400. The final stage is exactly the finished workflow shown later on this page._ ## Start from a skeleton Create a file called `record-shipped-orders.json` with the root keys: ```json title="record-shipped-orders.json" { "name": "record-shipped-orders", "description": "Records shipped orders from the store's web hook, creating the customer if needed.", "enabled": false, "meta": { "displayName": "Record shipped orders" }, "steps": [] } ``` This is already a document the API accepts. It just doesn't do anything yet. | Key | Rules | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Required. Letters, numbers, `-`, `_` and `.`, up to 128 characters, no spaces. Kustomer stores it lowercased and trimmed, and it must be unique in your org, so write it in lowercase yourself. The builder makes the name from the title the same way: `Record shipped orders` becomes `record-shipped-orders` | | `description` | Up to 4,096 characters | | `enabled` | New workflows are created disabled. Keep it `false` until you've tested | | `meta` | Must be **flat**: up to 100 keys matching `^[a-zA-Z][a-zA-Z0-9-_]{1,64}$`, each holding a string (up to 1,024 characters), number, boolean or `null`. Nested objects are rejected. `displayName` is the title the builder shows, so it can have spaces and capitals | | `trigger`, `steps` | Added in the next sections | The root accepts a few more keys, such as `replaces` and `logging`. Unknown keys are rejected. The [Workflow JSON reference](https://developer.kustomer.com/docs/workflows/workflow-json) lists them all, so this page only covers what you need. ## Add the trigger The trigger names the event that starts a run. For a web hook it's the hook's `eventName`: ```json title="Add after meta" "trigger": { "id": "1", "eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive", "transitions": [] }, ``` Replace `5825c452be3af71000ac6462` with your hook's ID. The trigger's `id` is always `"1"`, so its output is **step 1**: everything later in the workflow reads the event at `/#steps.1…`. For a web hook, step 1 is the hook transaction, and the fields your store posted are under `attributes.data`. When the store posts this: ```json title="What the store posts" { "orderNumber": "4417", "email": "jane@example.com", "name": "Jane Rivera", "total": "129.90" } ``` the email is at `/#steps.1.attributes.data.email` and the order number at `/#steps.1.attributes.data.orderNumber`. Record triggers, such as `kustomer.conversation.update`, flatten the record instead: see [What the trigger outputs](https://developer.kustomer.com/docs/workflows/triggers-and-actions#what-the-trigger-outputs). `transitions` is empty for now, so a run would end right after the trigger. Transitions need a step to point at, which comes next. ## Add the first step Steps go in the `steps` array. Add one that looks up the customer by the email in the payload, and point the trigger at it: ```json title="record-shipped-orders.json (trigger and steps)" "trigger": { "id": "1", "eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive", "transitions": [ { "target": "find-customer", "condition": { "op": "and", "values": [ { "op": "exists", "values": ["/#steps.1.attributes.data.email"] }, { "op": "exists", "values": ["/#steps.1.attributes.data.orderNumber"] } ] }, "meta": { "name": "Has an email and order number" } } ] }, "steps": [ { "id": "find-customer", "action": "kustomer.customer.find-by-email", "params": { "email": "/#steps.1.attributes.data.email" }, "meta": { "displayName": "Find the customer" }, "transitions": [] } ] ``` The step has four parts: - **`id`** is how the rest of the workflow refers to this step: its output will be stored at `/#steps.find-customer`. The builder generates random IDs such as `SAk4K54Ly`; in JSON, pick readable ones. Use letters, numbers, `-` and `_`, and no dots, because transition targets can't contain dots. - **`action`** is what the step does. Action names and their required inputs are in [Triggers and actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions#built-in-actions). In the builder, this one is **Customer: Lookup (By Email)**. - **`params`** are the action's inputs. `"/#steps.1.attributes.data.email"` is a **reference**: because the whole string starts with `/#`, it resolves to the value at that path and keeps its type. Any other string is a Handlebars template and always produces text. [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables) covers references, templates and functions in depth. - **`meta.displayName`** is the step's title in the builder. The trigger's transition has a **target** (a step ID) and a **condition**. This condition is an `and` group, so the run only reaches `find-customer` when the payload has both an email and an order number. If no transition matches, the run ends successfully. That's how most runs end, so a strict first condition is the cheapest filter you have. `meta.name` labels the branch in the builder; name every condition so the canvas reads well. ## Use the lookup's output A step's output is stored under its ID, so the next step can use the customer the lookup found. Add a step that records the order on that customer, and a transition from the lookup to it: ```json title="Add to find-customer's transitions" { "target": "record-order", "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "Customer found" } } ``` ```json title="Add to steps" { "id": "record-order", "action": "kustomer.kobject.create-with-customer", "params": { "klassName": "order", "customer": "/#steps.find-customer.id", "title": "Order {{{steps.1.attributes.data.orderNumber}}}", "data": { "orderNumberStr": "/#steps.1.attributes.data.orderNumber", "totalNum": "/#fn:parseFloat,steps.1.attributes.data.total" } }, "meta": { "displayName": "Record the order" }, "transitions": [] } ``` Three kinds of values appear in these params: | Value | Kind | Result | | --------------------------------------------------- | --------- | --------------------------------------------------------------------------- | | `"/#steps.find-customer.id"` | Reference | The customer's ID, read from the lookup's output | | `"Order {{{steps.1.attributes.data.orderNumber}}}"` | Template | The text `Order 4417`. Triple braces insert the value without HTML escaping | | `"/#fn:parseFloat,steps.1.attributes.data.total"` | Function | The number `129.9`. Function arguments are bare paths, without `/#` | The transition checks `exists` because **a lookup that finds nothing doesn't fail**. The API answers `404`, the step succeeds with empty output, and `/#steps.find-customer.id` is empty. Without the check, `record-order` would run with no customer. `transitions: []` makes `record-order` a final step. The `order` Klass and its `orderNumberStr` and `totalNum` fields are examples: use a Klass and fields that exist in your org. See [Custom objects](https://developer.kustomer.com/docs/concepts/custom-objects). ## Branch: find or create Right now a new customer's order is dropped: the lookup comes back empty, no transition matches, and the run ends. Add a second transition for that case, a step that creates the customer, and a step that records the order on the new customer: ```json title="find-customer's transitions" "transitions": [ { "target": "record-order", "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "Customer found" } }, { "target": "create-customer", "condition": { "op": "dne", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "No customer yet" } } ] ``` ```json title="Add to steps" { "id": "create-customer", "action": "kustomer.customer.create", "params": { "name": "/#steps.1.attributes.data.name", "email": "/#steps.1.attributes.data.email" }, "meta": { "displayName": "Create the customer" }, "transitions": [ { "target": "record-order-new-customer", "condition": { "op": "exists", "values": ["/#steps.create-customer.id"] }, "meta": { "name": "Customer created" } } ] }, { "id": "record-order-new-customer", "action": "kustomer.kobject.create-with-customer", "params": { "klassName": "order", "customer": "/#steps.create-customer.id", "title": "Order {{{steps.1.attributes.data.orderNumber}}}", "data": { "orderNumberStr": "/#steps.1.attributes.data.orderNumber", "totalNum": "/#fn:parseFloat,steps.1.attributes.data.total" } }, "meta": { "displayName": "Record the order for the new customer" }, "transitions": [] } ``` _The find-or-create shape. The web hook trigger (step 1) goes to find-customer when the payload has an email and an order number. From find-customer, the first transition goes to record-order when the customer was found, and the second goes to create-customer when there is no customer yet. create-customer goes to its own final step, record-order-new-customer, when the new customer's ID exists._ A few rules shape this: - **Transitions are checked in order, and the first match wins.** `exists` and `dne` can't both pass here, so the order doesn't matter. When conditions can overlap, put the most specific first. - **`dne`** ("does not exist") passes when the value is empty, `0`, `false` or missing. `exists` is its opposite. The full operator list is in [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#operators). - **Branches don't merge back.** Each branch ends in its own final step, which is why the order step appears twice with a different `customer`. Kustomer's own templates are built the same way. - **Transitions can't loop back** to a step that's already on the path. The API rejects the save (see [Validation errors](#validation-errors-youll-hit)). To repeat work later, [schedule a callable workflow](https://developer.kustomer.com/docs/workflows/patterns#schedule-follow-ups-instead-of-looping). ## Handle errors A Kustomer action that gets a `4xx` other than `404`, such as a validation error, **fails** the step. Without an error case, the run stops and the failure is recorded on the workflow. If the store sends an email Kustomer won't accept, `create-customer` fails that way. To stop quietly instead, add `errorCases` to the step, before its `transitions`: ```json title="Add to create-customer" "errorCases": [ { "condition": { "op": "or", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] }, "errorAction": { "type": "stop-processing" } } ], ``` - While error cases are checked, the error is at **`/#error`**: the first item of the API's `errors` array, with `status`, `code`, `title`, `detail` and `source`. It's never at `/#steps.create-customer.error`. - An error case's `condition.op` must be `and` or `or`, with up to five conditions inside. An empty `values` array matches every error. Cases are checked in order. - `stop-processing` ends the run without recording a failure. `continue` moves on to the step's transitions with no output, which is why `create-customer`'s transition checks `exists`. Don't use `retry-workflow` or `retry-action`: neither retries. Leave the error case out if you'd rather see every rejected payload in the workflow's error log. > **Error cases are JSON-only:** The classic builder has no editor for error cases. Add them in JSON, either with the API as shown here or with **Edit workflow JSON** in the builder. > **A 5xx restarts the run from step 1:** When a Kustomer action gets a `5xx`, error cases are skipped and Kustomer runs the workflow again **from step 1**. Steps that already succeeded run again, so a step that creates something can create it twice, and the retry isn't recorded in the error log. See [Make steps safe to repeat](https://developer.kustomer.com/docs/workflows/patterns#make-steps-safe-to-repeat) for ways to guard against that. ## The finished workflow ```json title="record-shipped-orders.json" { "name": "record-shipped-orders", "description": "Records shipped orders from the store's web hook, creating the customer if needed.", "enabled": false, "meta": { "displayName": "Record shipped orders" }, "trigger": { "id": "1", "eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive", "transitions": [ { "target": "find-customer", "condition": { "op": "and", "values": [ { "op": "exists", "values": ["/#steps.1.attributes.data.email"] }, { "op": "exists", "values": ["/#steps.1.attributes.data.orderNumber"] } ] }, "meta": { "name": "Has an email and order number" } } ] }, "steps": [ { "id": "find-customer", "action": "kustomer.customer.find-by-email", "params": { "email": "/#steps.1.attributes.data.email" }, "meta": { "displayName": "Find the customer" }, "transitions": [ { "target": "record-order", "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "Customer found" } }, { "target": "create-customer", "condition": { "op": "dne", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "No customer yet" } } ] }, { "id": "record-order", "action": "kustomer.kobject.create-with-customer", "params": { "klassName": "order", "customer": "/#steps.find-customer.id", "title": "Order {{{steps.1.attributes.data.orderNumber}}}", "data": { "orderNumberStr": "/#steps.1.attributes.data.orderNumber", "totalNum": "/#fn:parseFloat,steps.1.attributes.data.total" } }, "meta": { "displayName": "Record the order" }, "transitions": [] }, { "id": "create-customer", "action": "kustomer.customer.create", "params": { "name": "/#steps.1.attributes.data.name", "email": "/#steps.1.attributes.data.email" }, "meta": { "displayName": "Create the customer" }, "errorCases": [ { "condition": { "op": "or", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] }, "errorAction": { "type": "stop-processing" } } ], "transitions": [ { "target": "record-order-new-customer", "condition": { "op": "exists", "values": ["/#steps.create-customer.id"] }, "meta": { "name": "Customer created" } } ] }, { "id": "record-order-new-customer", "action": "kustomer.kobject.create-with-customer", "params": { "klassName": "order", "customer": "/#steps.create-customer.id", "title": "Order {{{steps.1.attributes.data.orderNumber}}}", "data": { "orderNumberStr": "/#steps.1.attributes.data.orderNumber", "totalNum": "/#fn:parseFloat,steps.1.attributes.data.total" } }, "meta": { "displayName": "Record the order for the new customer" }, "transitions": [] } ] } ``` Kustomer adds an `appVersion`, such as `kustomer-^1.2.1`, to steps saved in the builder. It's optional when you write JSON. ## Create it `POST /v1/workflows` ([reference](https://developer.kustomer.com/api/workflows/create-a-workflow)) Preview the request first, then send it: ```bash title="kusty" kusty api POST /v1/workflows --data @record-shipped-orders.json --dry-run kusty api POST /v1/workflows --data @record-shipped-orders.json --fields id,attributes.name,attributes.enabled --output json ``` ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/workflows" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ --data @record-shipped-orders.json ``` ```json title="201 Created (abridged)" { "data": { "type": "workflow", "id": "6716c1d2c4e5f60012ab3700", "attributes": { "name": "record-shipped-orders", "enabled": false, "trigger": { "id": "1", "eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive", "transitions": [ … ] }, "steps": [ … ], "lastError": {} } } } ``` Keep `data.id`. You'll use it to update the workflow, add variables and read its failures: ```bash export WORKFLOW_ID=6716c1d2c4e5f60012ab3700 ``` kusty checks the body against the API's JSON Schema before it sends anything, so a misspelled key or a nested `meta` fails locally with `SCHEMA_VALIDATION_FAILED` (exit code 2). Checks that need your org, such as unique names and the loop check, happen on the server. See [Validation errors](#validation-errors-youll-hit). ## Reference variables by ID This workflow doesn't need a secret, but many do: for example, a [REST step](https://developer.kustomer.com/docs/workflows/code-steps) that calls your warehouse's API with a token. Store the token in a **workflow variable**, never in params. Variables belong to one workflow, so create them after the workflow exists: ```bash title="kusty" kusty api POST /v1/workflows/$WORKFLOW_ID/variables \ --data '{ "name": "warehouseToken", "value": "wh_live_…", "type": "string" }' \ --fields id,attributes.name --output json ``` ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/workflows/$WORKFLOW_ID/variables" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "warehouseToken", "value": "wh_live_…", "type": "string" }' ``` ```json title="201 Created (abridged)" { "data": { "id": "6716c1d2c4e5f60012ab36f1", "type": "variable", "attributes": { "name": "warehouseToken", "maskedValue": "…", "type": "string" } } } ``` Reference the variable by its **ID**, `data.id`, not its name. As a whole param use `/#variables.`, and inside a template use `{{{variables.}}}`: ```json title="A REST step's headers" { "headers": { "Content-Type": "application/json", "Authorization": "Bearer {{{variables.6716c1d2c4e5f60012ab36f1}}}" } } ``` `{{{variables.warehouseToken}}}` resolves to nothing, and so does an ID from another workflow. Variable values are strings of up to 1,024 characters, and the API only ever returns the last six characters. Then add the step with a [`PUT`](#update-it), as described below. ## Turn it on and test A disabled workflow doesn't run, so testing a hook workflow means enabling it. Test in a sandbox org if you can, because this workflow creates real customers and orders. #### Enable it and turn on logging Send only the keys you're changing: ```bash title="kusty" kusty api PUT /v1/workflows/$WORKFLOW_ID --data '{ "enabled": true, "logging": true }' ``` ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/workflows/$WORKFLOW_ID" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \ -d '{ "enabled": true, "logging": true }' ``` `logging: true` records every run for the next hour, the same as **Debug Mode** in the builder's **View Logs**. #### Wait about a minute Workflows are cached, so a created, enabled or changed workflow takes about a minute to reach new runs. #### Post one payload per path Send an order for an existing customer, one for a new email, and one without an `orderNumber`: ```bash curl -X POST "$HOOK_URL" \ -H "Authorization: Bearer $KUSTOMER_HOOK_KEY" -H "Content-Type: application/json" \ -d '{ "orderNumber": "4417", "email": "jane@example.com", "name": "Jane Rivera", "total": "129.90" }' ``` #### Read the runs Open the workflow in the builder and select **View Logs** to see each run's path and every step's input and output. The first should end at `record-order`, the second at `record-order-new-customer`, and the third right after the trigger. Failed runs are also in [`GET /v1/workflows/{id}/processes`](https://developer.kustomer.com/api/workflows/get-processes-errors-by-workflow). See [Debugging](https://developer.kustomer.com/docs/workflows/debugging). ## Update it `PUT /v1/workflows/{id}` ([reference](https://developer.kustomer.com/api/workflows/update-a-workflow-based-on-the-id)) `PUT /v1/workflows/{id}` updates **only the root keys you send**. Each key you send replaces the stored value whole, and keys you leave out stay as they are. That's how the builder works too: its on/off switch sends only `enabled`, and **Save** sends `trigger` and `steps`. | To change | Send | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | | On or off | `{ "enabled": false }` | | The title | The whole `meta` object, because it's replaced as a unit: `{ "meta": { "displayName": "Record shipped store orders" } }` | | Any step or transition | `trigger` **and** `steps`, together | Always send `trigger` and `steps` together. The trigger's transitions point at step IDs, so replacing one without the other can leave a transition aimed at a step that no longer exists, and the run fails with `next step not found`. To change the logic, fetch the current document, keep only the keys you're changing, edit, preview and send: ```bash title="kusty" kusty api GET /v1/workflows/$WORKFLOW_ID --output json \ | jq '.data.attributes | {trigger, steps} | del(.steps[]._id)' > logic.json # edit logic.json kusty api PUT /v1/workflows/$WORKFLOW_ID --data @logic.json --dry-run kusty api PUT /v1/workflows/$WORKFLOW_ID --data @logic.json ``` ```bash title="cURL" curl -s "https://acme.api.kustomerapp.com/v1/workflows/$WORKFLOW_ID" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ | jq '.data.attributes | {trigger, steps} | del(.steps[]._id)' > logic.json # edit logic.json curl -X PUT "https://acme.api.kustomerapp.com/v1/workflows/$WORKFLOW_ID" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \ --data @logic.json ``` - Don't send back what the API adds: `lastError`, `editable`, `containsVariables`, the timestamps, and the `_id` on each stored step. Selecting keys with `jq`, as above, leaves them out. - Keep step IDs stable. Every `/#steps.` reference and every `target` depends on them. If you rename a step, update all of them. - Changing `name` changes how the workflow is listed; look workflows up by `id`, which never changes. - Kustomer keeps the 30 most recent versions of a workflow. The builder's version picker opens older ones read-only. - Allow about a minute before testing the change. ## Validation errors you'll hit The API validates every create and update. These are the errors you're most likely to see while writing JSON by hand: | Status | Error mentions | Cause and fix | | ------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `Step with ID transitions to a previous step with ID :` | A transition points back to a step already on the path. Workflows can't loop. To repeat work later, [schedule a callable workflow](https://developer.kustomer.com/docs/workflows/patterns#schedule-follow-ups-instead-of-looping) | | `400` | `uniqueItems` | Two identical transitions on one step. Remove the duplicate | | `409` | the name | Another workflow in the org already has this `name`. Names are compared after lowercasing, so `Record-Shipped-Orders` and `record-shipped-orders` clash. Pick a new name, or update the existing workflow instead | | `400` | `additionalProperties` | An unknown key, a read-only attribute copied from a `GET`, or a nested object in `meta` | | `400` | `target` pattern | A step ID used as a target contains a dot | | `400` | `maxItems` | More than 10 transitions or 10 error cases on one step, or more than 5 conditions in an error case | | `400` | `Invalid app settings configuration passed` | A `{{{ setting … }}}` template refers to a setting that doesn't exist | | `409` | | The workflow was installed by an app and can't be edited. You can still turn it on or off and toggle logging. To change it, [copy it and use `replaces`](https://developer.kustomer.com/docs/workflows/workflow-json#override-an-app-workflow-with-replaces) | Some mistakes pass validation and only show up at run time: a target that doesn't match any step ID (`next step not found`), a reference to a misspelled path (it silently resolves to nothing), or a variable referenced by name. Test every path, as described above. ## Next --- # Workflow patterns > A cookbook of proven workflow shapes, each with a diagram, a JSON snippet and its pitfalls. Covers find or create, guarding update triggers with changes, branching on lookups, regex keyword tagging, calling your own API, callable workflows, scheduled follow-ups instead of loops, idempotent steps for retries, and copying workflows between orgs. Source: https://developer.kustomer.com/docs/workflows/patterns Most workflows are built from a handful of shapes. This page collects the ones that hold up in production, with the mistakes each one avoids. Every snippet is valid workflow JSON; the step IDs are readable on purpose, and the tag, workflow and variable IDs are placeholders for your org's own. Two habits make every pattern work better: - **Keep each workflow small**: one trigger, one or two checks, one outcome. Split bigger flows into a caller and [callable workflows](#reuse-logic-with-a-callable-workflow). - **Create workflows disabled**, add their variables, test every path, then enable them. See [Write a workflow in JSON](https://developer.kustomer.com/docs/workflows/write-workflow-json). | Pattern | Use it when | | --------------------------------------------------------------------------------------- | ------------------------------------------------------ | | [Find or create](#find-or-create) | An event identifies a record that may not exist yet | | [Guard update triggers with changes](#guard-update-triggers-with-changes) | You use any `*.update` trigger | | [Branch on lookup results](#branch-on-lookup-results) | The decision depends on data the event doesn't carry | | [Tag on a keyword with a regex](#tag-on-a-keyword-with-a-regex) | You need to find a pattern in text | | [Call your own API and handle the response](#call-your-own-api-and-handle-the-response) | The logic lives in your service | | [Reuse logic with a callable workflow](#reuse-logic-with-a-callable-workflow) | Several workflows, or an AI agent, need the same steps | | [Schedule follow-ups instead of looping](#schedule-follow-ups-instead-of-looping) | Something should happen later, or be checked again | | [Make steps safe to repeat](#make-steps-safe-to-repeat) | A workflow creates records or calls other systems | | [Copy a workflow to another org](#copy-a-workflow-to-another-org) | You promote workflows from a sandbox to production | ## Find or create **Use it when** an event carries an identifier, such as an email or an external ID, and you need the matching record whether or not it exists yet. Kustomer's own email integration uses this shape to match each sender to a customer. _Find or create. A web hook trigger goes to a Customer: Lookup (By Email) step. If the customer was found, the first transition goes to Record the order. If there is no customer yet, the second transition goes to Customer: Create, which then goes to its own Record the order step._ ```json title="The lookup and its two branches" { "id": "find-customer", "action": "kustomer.customer.find-by-email", "params": { "email": "/#steps.1.attributes.data.email" }, "transitions": [ { "target": "record-order", "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "Customer found" } }, { "target": "create-customer", "condition": { "op": "dne", "values": ["/#steps.find-customer.id"] }, "meta": { "name": "No customer yet" } } ] } ``` **Watch out for:** - **A lookup that finds nothing succeeds** with empty output, so always branch on `exists` and `dne` of its `id`. Without the check, the next step runs with an empty ID. - **Branches don't merge back.** Give each branch its own final step, even if it repeats another one. - **Prefer a stable identifier.** Emails change. If your system has its own ID, look up by it (`kustomer.customer.find-by-external-id`) and store it on the record you create. - **Retries can repeat the create.** See [Make steps safe to repeat](#make-steps-safe-to-repeat). The [JSON tutorial](https://developer.kustomer.com/docs/workflows/write-workflow-json) builds this workflow from scratch. ## Guard update triggers with changes **Use it when** you use any `*.update` trigger. They fire on every change to the record, including every new message on a conversation and every change your own workflow makes. A condition on the record's current state matches again and again; a condition on `changes` matches only the update that made the change. _Guarding an update trigger. A Conversation updated trigger has one transition, to Add tags, taken only when changes.attributes.status.after equals done and the conversation doesn't already have the marker tag. Every other update matches no transition, so the run ends right after the trigger._ ```json title="Trigger with a changes guard and a marker" { "id": "1", "eventName": "kustomer.conversation.update", "transitions": [ { "target": "tag-resolved", "condition": { "op": "and", "values": [ { "op": "eq", "values": ["/#steps.1.changes.attributes.status.after", "done"] }, { "op": "not-contains", "values": ["/#steps.1.tags", "64f1c0ffee5ca1ab1e000003"] } ] }, "meta": { "name": "Just closed, not yet tagged" } } ] } ``` The second condition is an **idempotency marker**: `tag-resolved` adds tag `64f1c0ffee5ca1ab1e000003`, so the workflow can never act twice on the same conversation, even if it's reopened and closed again. Drop it if you want that. **Watch out for:** - **Gate on the change, not the state.** `/#steps.1.changes.attributes.status.after` is set only when _this_ update changed the status. `/#steps.1.status` is `done` for every later update too. - **Custom fields change as one `custom` entry**, so the path is `/#steps.1.changes.attributes.custom.after.`. See [Conditions on what changed](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#conditions-on-what-changed). - **Skip records you didn't mean to handle.** For message triggers, add `dne` on `/#steps.1.importedAt` and `neq` of `/#steps.1.source` and `"bulk"`. To skip messages written by bots, look up the author and check `neq` of their `userType` and `"machine"`. - **Mark the text you post.** A workflow that writes messages or notes should include a marker such as `[auto]` and check `not-contains` on the preview, so it doesn't answer itself. - **`neq` passes when the field is missing.** Pair it with `exists` when that matters. ## Branch on lookup results **Use it when** the decision depends on data the event doesn't carry, such as a field on the customer when a conversation is created. Look the record up, then branch on its output. _Branching on a lookup. A Conversation created trigger goes to Customer: Lookup (By ID) when the conversation has a customer. From the lookup, the first transition goes to Add the VIP tag when the customer's vipBool field is true, and the second goes to Add the gold tag when tierStr equals gold. A VIP gold customer only gets the VIP tag, because the first match wins._ ```json title="Trigger and lookup" { "trigger": { "id": "1", "eventName": "kustomer.conversation.create", "transitions": [ { "target": "lookup-customer", "condition": { "op": "exists", "values": ["/#steps.1.customer"] } } ] }, "steps": [ { "id": "lookup-customer", "action": "kustomer.customer.find", "params": { "identifier": "/#steps.1.customer" }, "transitions": [ { "target": "tag-vip", "condition": { "op": "eq", "values": ["/#steps.lookup-customer.custom.vipBool", true] }, "meta": { "name": "Customer is VIP" } }, { "target": "tag-gold", "condition": { "op": "eq", "values": ["/#steps.lookup-customer.custom.tierStr", "gold"] }, "meta": { "name": "Gold tier" } } ] }, { "id": "tag-vip", "action": "kustomer.conversation.append-tags", "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000002"] }, "transitions": [] }, { "id": "tag-gold", "action": "kustomer.conversation.append-tags", "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000005"] }, "transitions": [] } ] } ``` **Watch out for:** - **The first match wins.** Put the most specific branch first. To apply both tags, chain the checks instead: VIP check, VIP tag, then a gold check after it. - **Guard the lookup.** The `exists` check on the trigger skips the lookup when a conversation has no customer. - **Put the reference first in `eq`.** The right side is cast to the left side's type, so `["/#steps.lookup-customer.custom.vipBool", true]` compares booleans. When the field is missing, no cast happens and `eq` is false. - **Tags, users and teams are IDs**, never names. - Use `append-tags` rather than `conversation.update` with `tags`, which replaces the whole list. ## Tag on a keyword with a regex **Use it when** you need to find a pattern in text: whole words, alternatives, or values such as order numbers. For a single plain keyword, a `contains` condition is simpler (see the tip below). _Tagging on a keyword. A Message created trigger goes to a Generic Regex Match step only for the first inbound email that has a preview. The regex step goes to Conversation: Add Tags when it found a match. Without a match, the run ends._ ```json title="Trigger, regex step and tag" { "trigger": { "id": "1", "eventName": "kustomer.message.create", "transitions": [ { "target": "match-refund", "condition": { "op": "and", "values": [ { "op": "eq", "values": ["/#steps.1.channel", "email"] }, { "op": "eq", "values": ["/#steps.1.directionType", "initial-in"] }, { "op": "exists", "values": ["/#steps.1.preview"] } ] }, "meta": { "name": "First inbound email" } } ] }, "steps": [ { "id": "match-refund", "action": "kustomer.regex-match.generic", "params": { "testString": "/#steps.1.preview", "regex": "\\b(refund|chargeback|money back)\\b", "flags": "i" }, "transitions": [ { "target": "tag-refund", "condition": { "op": "exists", "values": ["/#steps.match-refund.match"] }, "meta": { "name": "Mentions a refund" } } ] }, { "id": "tag-refund", "action": "kustomer.conversation.append-tags", "params": { "id": "/#steps.1.conversation", "tags": ["64f1c0ffee5ca1ab1e000004"] }, "transitions": [] } ] } ``` The regex step's output is `{ match, captures, index, original }`. Read the first capture group at `/#steps.match-refund.captures.0`. **Watch out for:** - **Pass flags in `flags`.** Inline flags such as `(?i)` are a syntax error and fail the step. - **Leave out `g`.** With it, `captures` holds the other full matches instead of the capture groups. - **Escape backslashes in JSON**: `\b` is written `"\\b"`. Try a pattern in the [regex step tester](https://developer.kustomer.com/docs/workflows/triggers-and-actions#regex-match) first. - **Missing inputs fail the step** with "Missing param testString" or "Missing param regex". The `exists` check on the preview prevents that. - **One message, one tag.** If you add branches for more keywords, the first match wins, as in [Branch on lookup results](#branch-on-lookup-results). > **Plain keywords without a regex:** `contains` is case-sensitive, so lowercase the text with a template first: `{ "op": "contains", "values": ["{{{toLower steps.1.preview}}}", "refund"] }`. Conditions accept templates as well as references. ## Call your own API and handle the response **Use it when** the logic lives in your own service: a pricing rule, a lookup in your database, a partner API. Call it with a REST step and branch on what it returns. _Calling your own API. A Conversation updated trigger goes to a Rest API: JSON step when the status just became done. The REST step's first transition goes to Add the synced tag when the status code is below 300. Its second, always-true transition goes to Note: Create, which records the failure._ ```json title="REST step with a catch-all branch" { "id": "post", "action": "kustomer.rest-api.json", "params": { "method": "post", "uri": "https://api.example.com/kustomer/closed", "headers": { "Content-Type": "application/json", "Authorization": "Bearer {{{variables.6716c1d2c4e5f60012ab36f1}}}" }, "data": { "conversationId": "/#steps.1.id", "customerId": "/#steps.1.customer" }, "json": true }, "transitions": [ { "target": "tag-synced", "condition": { "op": "lt", "values": ["/#steps.post.response.statusCode", 300] }, "meta": { "name": "Accepted" } }, { "target": "note-failed", "condition": { "op": "true", "values": [true] }, "meta": { "name": "Anything else" } } ] } ``` **Watch out for:** - **A REST step never fails on a status code.** A `404` or `503` is a successful step, and error cases don't apply. Branch on `/#steps.post.response.statusCode`, and end with an always-true branch so nothing falls through silently. - **Reference secrets by variable ID**, as in the `Authorization` header above. `{{{variables.serviceToken}}}`, by name, resolves to nothing. See [Reference variables by ID](https://developer.kustomer.com/docs/workflows/write-workflow-json#reference-variables-by-id). - **Set `Content-Type` yourself**, and set `"json": true` to read fields of the response at `/#steps.post.body.`. - **Only set `allowNetworkRetry`** if your endpoint is safe to call twice: a timed-out request may already have reached it. - **Stay fast.** The step waits up to 60 seconds, and the run is stopped after 5 minutes. Hand slow work to your service and report back through an [inbound web hook](https://developer.kustomer.com/docs/events/inbound-webhooks). - **Use Kustomer actions for Kustomer data**, not REST calls to the Kustomer API. The full guide, including signing and URL rules, is [Call your own code](https://developer.kustomer.com/docs/workflows/code-steps). ## Reuse logic with a callable workflow **Use it when** several workflows need the same steps, when something should run later (see the [next pattern](#schedule-follow-ups-instead-of-looping)), or when one of Kustomer's AI agents should call a workflow as a tool. A callable workflow has no event: its trigger declares the inputs it accepts. _A callable workflow reused by two callers. Workflow A and workflow B each have a Schedule a Workflow step that passes inputs to the callable workflow's trigger. The callable trigger goes to Conversation: Lookup (By ID) when a conversationId was passed, then to Note: Create, then to Workflow: Send Response, which returns outputs to callers that wait for them._ ```json title="Callable trigger" { "id": "1", "callable": true, "schema": { "type": "object", "properties": { "conversationId": { "type": "string" }, "reason": { "type": "string" } }, "required": ["conversationId"] }, "transitions": [ { "target": "load", "condition": { "op": "exists", "values": ["/#steps.1.conversationId"] } } ] } ``` **Watch out for:** - **Inputs are step 1.** The caller's inputs are cast to `schema` and become step `1`'s whole output, so read `/#steps.1.conversationId`, never `/#steps.1.inputs.conversationId`. - **Don't set `eventName`.** Kustomer sets it to `kustomer.workflow..call` when you save. - **Re-fetch before acting.** Inputs are a snapshot from when the caller ran. Look the record up again, as `load` does, and check it's still in the state you expect. - **Outputs are for callers that wait.** `kustomer.workflow.respond` returns its `outputs` to a caller that waits for a result, such as an AI agent using the workflow as a tool. Declare them in the root `response.schema`, which the builder edits on its **Output Parameters** tab. - **Exports lose the outputs.** The builder's **Export JSON** and **Duplicate** leave out `response`, so re-add the output parameters on the copy. - Your own code can't call a workflow directly. Post to an [inbound web hook](https://developer.kustomer.com/docs/events/inbound-webhooks) and trigger a workflow from it instead. The full callable example, with `load`, `note` and `respond`, is in [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows). ## Schedule follow-ups instead of looping **Use it when** something should happen later or be checked again: a reminder after 24 hours, a follow-up if the customer hasn't replied. Transitions can't point back to an earlier step, and the API rejects a workflow that tries. Schedule a callable workflow instead. _A scheduled follow-up. A Conversation created trigger goes to a Schedule a Workflow step that runs the follow-up workflow in 24 hours. Twenty-four hours later the callable trigger receives the conversationId and goes to Conversation: Lookup (By ID). If the conversation's status isn't done, it goes to Note: Create to add a follow-up note; otherwise the run ends._ ```json title="In the caller: run send-followup in 24 hours" { "id": "schedule-followup", "action": "kustomer.app.scheduler.schedule-workflow", "appVersion": "scheduler-^1.0.1", "params": { "workflowId": "6716c1d2c4e5f60012ab3702", "runAt": { "type": "relative", "value": 86400 }, "inputs": { "conversationId": "/#steps.1.id", "reason": "No reply after 24 hours" } }, "transitions": [] } ``` `workflowId` is the callable workflow's ID. The **Schedule a Workflow** action comes from the Scheduler app, which must be installed. **Watch out for:** - **`relative` is whole seconds** from now. `absolute` is an ISO 8601 timestamp. Any other `type` fails with "runAt type must be one of absolute or relative". - **The `date` helper counts backwards.** `{{date 'hours' count=24}}` is 24 hours **ago**. For a time in the future, use a negative count, `{{date 'hours' count=-24}}`, or a `relative` delay. See the [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference). - **Re-check the state** in the callable workflow. By the time it runs, the conversation may be closed, as the `load` and `Status isn't done` check handle above. - **Keep inputs small**: IDs, not whole records. Oversized inputs fail with "Payload too large to schedule workflow". - **The ID is org-specific.** When you [copy the caller](#copy-a-workflow-to-another-org), point `workflowId` at the target org's copy of the callable workflow. ## Make steps safe to repeat **Use it when** a workflow creates records or calls other systems. When a Kustomer action gets a `5xx` from the API, the step's error cases are skipped and Kustomer runs the workflow again **from step 1**. Steps that already succeeded run again, and the retry doesn't appear in the workflow's error log. Design every step so that running it twice does no harm. _An idempotent create. A web hook trigger goes to Conversation: Lookup (By External ID), searching for the order's external ID. If no conversation has that ID yet, it goes to Conversation: Create, which sets the external ID, then to Note: Create. If the conversation already exists, for example because this is a retry, no transition matches and the run ends without creating a duplicate._ ```json title="Check before you create" { "steps": [ { "id": "find-conversation", "action": "kustomer.conversation.find-by-external-id", "params": { "externalId": "order-{{{steps.1.attributes.data.orderNumber}}}" }, "transitions": [ { "target": "create-conversation", "condition": { "op": "dne", "values": ["/#steps.find-conversation.id"] }, "meta": { "name": "Not created yet" } } ] }, { "id": "create-conversation", "action": "kustomer.conversation.create", "params": { "customer": "/#steps.find-customer.id", "externalId": "order-{{{steps.1.attributes.data.orderNumber}}}", "name": "Order {{{steps.1.attributes.data.orderNumber}}} shipped" }, "transitions": [ { "target": "add-note", "condition": { "op": "exists", "values": ["/#steps.create-conversation.id"] } } ] }, { "id": "add-note", "action": "kustomer.note.create", "params": { "conversation": "/#steps.create-conversation.id", "body": "Shipped from the store." }, "transitions": [] } ] } ``` This excerpt follows a [find-or-create](#find-or-create) step called `find-customer`. On a retry, `find-conversation` finds the conversation the first attempt made, and the run ends instead of creating a second one. **Watch out for:** - **Order matters.** Put lookups and repeatable actions, such as adding a tag, first. Put creates and calls to other systems as late as you can, ideally last. - **Check before you create**, by external ID as above, or by a marker: a tag or custom field you check at the trigger and set in the same run. - **Make REST calls idempotent** on your side: send a stable key such as the conversation ID, and ignore repeats. - `4xx` errors don't retry. They fail the step, and its [error cases](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#handle-errors) decide what happens. ## Copy a workflow to another org **Use it when** you build in a sandbox and promote to production, or share a workflow with another team. The JSON copies cleanly; the IDs inside it don't. _Copying a workflow between orgs, as five steps you run. Fetch the workflow from the sandbox and keep name, description, meta, trigger and steps. Replace org-specific IDs such as tags, teams, users, the hook ID in eventName and scheduled workflowIds. Create it disabled in production with POST /v1/workflows. Recreate its variables and update every variable ID with PUT, sending trigger and steps together. Test it, then enable it._ ```bash title="Copy with kusty profiles" kusty --profile sandbox api GET /v1/workflows/$SANDBOX_WORKFLOW_ID --output json \ | jq '.data.attributes | {name, description, meta, trigger, steps} | del(.steps[]._id)' > wf.json # List every 24-character ID in the document: each one belongs to the sandbox grep -oE '[0-9a-f]{24}' wf.json | sort | uniq -c # After replacing them: kusty --profile production api POST /v1/workflows --data @wf.json --dry-run kusty --profile production api POST /v1/workflows --data @wf.json --fields id --output json ``` Leaving `enabled` out creates the copy disabled. Then create each variable in the new workflow, replace every `variables.` with the new ID, and send `trigger` and `steps` together in a `PUT`, as in [Update it](https://developer.kustomer.com/docs/workflows/write-workflow-json#update-it). **Watch out for:** - **Variables are per workflow.** A copy has none, and its references still point at the old workflow's variable IDs, which resolve to nothing. Variables are referenced by ID, so recreating them under the same names isn't enough. - **Hooks and callable workflows have IDs too.** A hook trigger's `eventName` contains the hook's ID, so create the hook in the target org first. Create callable workflows before the workflows that schedule them, so you know the IDs to put in `workflowId`. - **Klass names and custom fields** must exist in the target org. - **Names must be unique** in the target org. A clash returns `409`. - **Builder exports are partial.** **Export JSON** and **Duplicate** keep only `name`, `description`, `meta`, `trigger` and `steps`. Copies start disabled, lose a callable workflow's output parameters, and drop its `eventName`, which Kustomer sets again on save. ## Related --- # Triggers and actions > Every built-in workflow trigger and action, the shape of trigger output, how Kustomer actions report not-found and errors, and how to build callable and scheduled workflows. Source: https://developer.kustomer.com/docs/workflows/triggers-and-actions A **trigger** names the event that starts a run. An **action** is a step that does something: finds or changes a record, calls a URL, or answers a caller. The built-in triggers and actions come from the Kustomer app, which every org has. Installed apps add their own. Use their names exactly as shown here in [workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json). ## Built-in triggers | Event name | In the builder | Fires when | | ---------------------------------- | ------------------------- | ------------------------------------------------------------------------------------- | | `kustomer.conversation.create` | Conversation Created | A conversation is created on any channel | | `kustomer.conversation.update` | Conversation Updated | Any conversation attribute changes, including on every new message | | `kustomer.customer.create` | Customer Created | A customer is created | | `kustomer.customer.update` | Customer Updated | A customer changes | | `kustomer.message.create` | Message Created | An inbound or outbound message is created | | `kustomer.message.update` | Message Updated | A message changes, for example its delivery status | | `kustomer.note.create` | Note Created | An agent or workflow adds a note | | `kustomer.note.update` | Note Updated | A note is edited | | `kustomer.company.create` | Company Created | A company is created | | `kustomer.company.update` | Company Updated | A company changes | | `kustomer.kobject..create` | KObject Created (_klass_) | A custom object of that Klass is created, for example `kustomer.kobject.order.create` | | `kustomer.kobject..update` | KObject Updated (_klass_) | A custom object of that Klass changes | | `kustomer.draft.create` | Draft Created | An agent starts a draft | | `kustomer.internal_thread.create` | Internal Thread Created | An internal collaboration thread starts | | `kustomer.internal_message.create` | Internal Message Created | A message is posted in an internal thread | Two more families start workflows: [inbound webhooks](https://developer.kustomer.com/docs/events/inbound-webhooks) (`kustomer.hooks...receive`) and [callable workflows](#callable-and-scheduled-workflows) (`kustomer.workflow..call`). Installed apps define their own, such as `kustomer.app..`. ### What the trigger outputs A record trigger's output, step `1`, is the record **flattened**: attributes sit at the top level, relationships become plain IDs, and the update diff is under `changes`. ```json title="kustomer.conversation.update — step 1 output (abridged)" { "id": "6512f0c2a4b1e1001a6b2c90", "name": "Order 4417 hasn't arrived", "status": "done", "priority": 3, "channels": ["email"], "tags": ["64f1c0ffee5ca1ab1e000001"], "assignedUsers": ["5f8481c6a22ab5b9d5f2dd99"], "assignedTeams": [], "messageCount": 4, "custom": { "orderNumberStr": "4417" }, "lastDone": { "createdAt": "2026-10-21T15:20:42.554Z", "createdBy": "5f8481c6a22ab5b9d5f2dd99" }, "customer": "6512ef18a4b1e1001a6b2c11", "brand": "5f8481c6a22ab5b9d5f2dd8b", "changes": { "attributes": { "status": { "op": "replace", "before": "open", "after": "done" } } } } ``` So `/#steps.1.status` is the current status, `/#steps.1.customer` is the customer's ID (look the customer up if you need more), and `/#steps.1.changes.attributes.status.after` is set only when _this_ update changed the status. Create triggers have no `changes`. Other trigger families shape step `1` differently: - **Inbound web hooks** put the posted body under `attributes.data`, so a field named `email` is `/#steps.1.attributes.data.email`. - **Callable workflows** get only the caller's inputs, cast to the trigger's `schema`. Read an input named `conversationId` as `/#steps.1.conversationId`, never `/#steps.1.inputs.conversationId`. > **Use the builder's sample:** Every trigger and action in the builder has an output schema and sample data. When you select fields from the builder's dropdowns, it writes the correct `/#steps…` path for you. Copy the paths into JSON rather than guessing them. ## Built-in actions The tables list each action's required inputs. Most actions accept many more optional ones, matching the fields of the record they write; the builder shows them all. **Conversations** | Action | In the builder | Required inputs | | ------------------------------------------- | ------------------------------------- | --------------- | | `kustomer.conversation.create` | Conversation: Create | `customer` | | `kustomer.conversation.update` | Conversation: Update | `id` | | `kustomer.conversation.find-by-id` | Conversation: Lookup (By ID) | `id` | | `kustomer.conversation.find-by-external-id` | Conversation: Lookup (By External ID) | `externalId` | | `kustomer.conversation.append-tags` | Conversation: Add Tags | `id`, `tags` | | `kustomer.conversation.remove-tags` | Conversation: Remove Tags | `id`, `tags` | | `kustomer.conversation.append-skills` | Conversation: Add Skills | `id`, `skills` | | `kustomer.conversation.remove-skills` | Conversation: Remove Skills | `id`, `skills` | | `kustomer.conversation.assign-user` | Conversation: Assign User | `id`, `user` | | `kustomer.conversation.assign-team` | Conversation: Assign Team | `id`, `team` | | `kustomer.conversation.ai-agent-handoff` | AI Agent Handoff | `id`, `user` | Use `append-tags` and `remove-tags` rather than `update` with `tags`: `update` replaces the whole array, so a concurrent change can be lost. **Customers** | Action | In the builder | Required inputs | | ---------------------------------------- | ------------------------------------------------ | ----------------------- | | `kustomer.customer.create` | Customer: Create | none | | `kustomer.customer.update` | Customer: Update | `id` | | `kustomer.customer.find` | Customer: Lookup (By ID) | `identifier` | | `kustomer.customer.find-by-email` | Customer: Lookup (By Email) | `email` | | `kustomer.customer.find-by-phone` | Customer: Lookup (By Phone) | `phone` | | `kustomer.customer.find-by-external-id` | Customer: Lookup (By External ID) | `externalId` | | `kustomer.customer.find-by-whatsapp` | Customer: Lookup (By WhatsApp Phone) | `whatsapp` | | `kustomer.customer.find-by-bsuid` | Customer: Lookup (By WhatsApp BSUID) | `bsuid`, `bsuidScopeId` | | `kustomer.customer.find-by-facebook-id` | Customer: Lookup (By Facebook Messenger User ID) | `pageId`, `userId` | | `kustomer.customer.find-by-instagram-id` | Customer: Lookup (By Instagram Thread ID) | `threadId` | | `kustomer.customer.append-email` | Customer: Append Email | `id`, `email` | | `kustomer.customer.append-phone` | Customer: Append Phone | `id`, `phone` | | `kustomer.customer.append-external-id` | Customer: Append External ID | `id`, `externalId` | | `kustomer.customer.merge` | Merge Customers | `source`, `target` | | `kustomer.message.find-last` | Customer: Lookup Last Message | `customer` | **Messages and notes** | Action | In the builder | Required inputs | | ----------------------------------- | -------------------------------- | ------------------------------------ | | `kustomer.message.create` | Message: Create | `customer`, `channel`, `app`, `size` | | `kustomer.message.update` | Message: Update | `id` | | `kustomer.message.find-by-id` | Message: Lookup (By ID) | `id` | | `kustomer.message.find-by-external` | Message: Lookup (By External ID) | `identifier` | | `kustomer.note.create` | Note: Create | `conversation`, `body` | | `kustomer.note.update` | Note: Update | `id` | | `kustomer.note.find-by-id` | Note: Lookup (By ID) | `id` | | `kustomer.note.find-by-external` | Note: Lookup (By External ID) | `identifier` | `message.create` records a message on a conversation. It doesn't send anything to the customer. To reply on a channel, see [Conversations and messages](https://developer.kustomer.com/docs/concepts/conversations-and-messages). **Custom objects and companies** | Action | In the builder | Required inputs | | --------------------------------------- | --------------------------------------------- | ---------------------------------------- | | `kustomer.kobject.create` | Custom Object (KObject): Create | `klassName`, `title`, `data` | | `kustomer.kobject.create-with-customer` | Custom Object (KObject): Create with Customer | `klassName`, `customer`, `title`, `data` | | `kustomer.kobject.update` | Custom Object (KObject): Update | `id`, `klassName`, `title`, `data` | | `kustomer.kobject.find` | Custom Object (KObject): Lookup (By ID) | `id`, `klassName` | | `kustomer.kobject.delete` | Custom Object (KObject): Delete | `id`, `klassName` | | `kustomer.klass.create` | Klass: Create | `name`, `status` | | `kustomer.klass.find-by-klassname` | Klass: Lookup (By Klass name) | `klassName` | | `kustomer.company.create` | Company: Create | `name` | | `kustomer.company.update` | Company: Update | `id` | | `kustomer.company.find` | Company: Lookup (By ID) | `identifier` | | `kustomer.company.find-by-external-id` | Company: Lookup (By External ID) | `externalId` | **Users and collaboration** | Action | In the builder | Required inputs | | ---------------------------------------- | -------------------------------- | ----------------------------------------------------------------------- | | `kustomer.user.find-by-id` | User: Find By ID | `id` | | `kustomer.user.find-by-email` | User: Find By Email | `email` | | `kustomer.internal_thread.create` | Internal thread: Create | `conversation`, `title`, `internalChannel` | | `kustomer.draft.internal-message-create` | Draft: Internal message | `customer`, `conversation` | | `kustomer.internal_message.create` | Internal message: Create (draft) | `draftId`, `body`, `internalThreadId`, `threadTitle`, `internalChannel` | **Utilities** | Action | In the builder | Required inputs | | ------------------------------------------ | ----------------------------------- | ------------------------------- | | `kustomer.rest-api.json` | Rest API: JSON | `uri`, `method` | | `kustomer.rest-api.generic` | Rest API: Generic | `uri`, `method` | | `kustomer.regex-match.generic` | Generic Regex Match | `testString`, `regex` | | `kustomer.workflow.respond` | Workflow: Send Response | `outputs` | | `kustomer.app.scheduler.schedule-workflow` | Schedule a Workflow (Scheduler app) | `workflowId`, `runAt`, `inputs` | ## How Kustomer actions succeed and fail Kustomer actions call the same APIs you do, and the step's outcome follows the response: | Response | What the step does | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `2xx` | Succeeds. The output is the record, flattened like trigger output | | `404` | **Succeeds with empty output.** A lookup that finds nothing isn't an error | | Other `4xx`, such as a validation error | Fails. `/#error` is the first item of the API's `errors` array, and the step's [error cases](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#handle-errors) decide what happens next | | `5xx` | **Error cases are skipped and the run starts again from step `1`.** Steps that already succeeded run again. These retries aren't recorded on the workflow's error log | Always supply required inputs. The request is sent even when a required input is missing, so a step with a missing or empty required input may fail or return empty output. `0`, `false` and `""` count as missing for this check. A param whose reference resolves to `null`, `undefined` or an empty object isn't sent at all. The one exception is `queue`, where `null` is kept and means "unassign". Because a `5xx` reruns the whole workflow, design steps before a risky one to be safe to repeat: prefer lookups and **Add Tags** over creating records, or look for the record before you create it. Because not-found isn't a failure, branch on the result after every lookup. This is the pattern Kustomer's own apps use: ```json title="Find or create" { "id": "find", "action": "kustomer.customer.find-by-email", "params": { "email": "/#steps.1.attributes.data.email" }, "transitions": [ { "target": "update", "condition": { "op": "exists", "values": ["/#steps.find.id", ""] }, "meta": { "name": "Customer exists" } }, { "target": "create", "condition": { "op": "dne", "values": ["/#steps.find.id", ""] }, "meta": { "name": "New customer" } } ] } ``` The REST actions behave differently: they never fail on a status code. See [Call your own code](https://developer.kustomer.com/docs/workflows/code-steps#handle-responses-and-failures). ## Utility actions ### REST API `kustomer.rest-api.json` sends `data` as JSON and, with `"json": true`, parses the response body into an object. `kustomer.rest-api.generic` sends `data` as a string, for example form-encoded or XML, and returns the body as a string. Both output `{ response: { statusCode, headers }, body }`. The full guide, including signing, secrets and URL restrictions, is in [Call your own code](https://developer.kustomer.com/docs/workflows/code-steps). ### Regex match Extracts values from text such as an email subject or message body. ```json { "id": "order-number", "action": "kustomer.regex-match.generic", "params": { "testString": "/#steps.1.preview", "regex": "order\\s*#?(\\d{4,})", "flags": "i" }, "transitions": [{ "target": "lookup-order", "condition": { "op": "exists", "values": ["/#steps.order-number.match"] } }] } ``` ```json title="Output" { "match": "order #4417", "captures": ["4417"], "index": 7, "original": "Hi, my order #4417 hasn't arrived" } ``` Reference the first capture group as `/#steps.order-number.captures.0`. Without a match, the output has only an empty `captures`, so check `exists` on `match` before using it. The step runs JavaScript's `RegExp` and `String.match()`: - **Pass flags in the `flags` param.** Inline flags such as `(?i)` are a syntax error. An invalid pattern fails the step with the engine's error, such as `Invalid regular expression: /(?i)refund/: Invalid group`. - **Leave out `g` to get capture groups.** With `g`, `match` is the first full match, `captures` holds the other full matches, and `index` and `original` are missing. - **Escape backslashes in JSON**: `\d` is written `"\\d"`. - If `regex` or `testString` resolves to nothing, the step fails with `Missing param regex` or `Missing param testString`. _A regex step tester. Type a pattern, toggle the i, g, m, s, u and y flags, and edit the sample text: the full match and capture groups are highlighted in the text. Beside it, the step JSON updates with the pattern escaped for JSON, each doubled backslash highlighted. The output panel shows the step's match, captures, index and original, and the references later steps use, such as /#steps.order-number.captures.0. Examples load an order number, keywords, an email address, the g flag (captures become the other matches) and an inline (?i) flag (the step fails with the engine's syntax error). Notes explain no match, case sensitivity, missing capture groups and the g flag._ ## Callable and scheduled workflows A **callable** workflow has no event trigger. Instead, its trigger declares the inputs it accepts, and something else calls it, most often the Scheduler app's **Schedule a Workflow** action. ```json title="Callable workflow: send-followup" { "name": "send-followup", "enabled": true, "trigger": { "id": "1", "callable": true, "schema": { "type": "object", "properties": { "conversationId": { "type": "string" }, "reason": { "type": "string" } }, "required": ["conversationId"] }, "transitions": [{ "target": "load", "condition": { "op": "exists", "values": ["/#steps.1.conversationId"] } }] }, "response": { "schema": { "properties": { "noteId": { "type": "string" } }, "required": [] } }, "steps": [ { "id": "load", "action": "kustomer.conversation.find-by-id", "params": { "id": "/#steps.1.conversationId" }, "transitions": [{ "target": "note", "condition": { "op": "neq", "values": ["/#steps.load.status", "done"] } }] }, { "id": "note", "action": "kustomer.note.create", "params": { "conversation": "/#steps.load.id", "body": "Follow up: {{steps.1.reason}}" }, "transitions": [{ "target": "respond", "condition": { "op": "true", "values": [true] } }] }, { "id": "respond", "action": "kustomer.workflow.respond", "params": { "outputs": { "noteId": "/#steps.note.id" } }, "transitions": [] } ] } ``` - Don't set `eventName`. Kustomer sets it to `kustomer.workflow..call` when you save. - A callable trigger needs `id`, `callable`, `schema` and `transitions`. In the builder, the **Input Parameters** tab of **Edit Input Parameters** writes `trigger.schema`. - The caller's inputs are cast to `schema` and become step `1`'s output, and nothing else is added, so `conversationId` is `/#steps.1.conversationId`. - The root `response.schema` declares the outputs the workflow sends back. It's the builder's **Output Parameters** tab. - `kustomer.workflow.respond` returns `outputs` to a caller that waits for a result, such as a Kustomer AI agent that calls the workflow as a tool. Add it at the end of each branch that should answer. The caller gets the `outputs` on success, "no response" if the run ends without reaching a respond step, or an error result if the run fails. - Calls to callable workflows aren't rate limited. - There's no public REST endpoint that calls a workflow directly. To run one from your own code, post to an [inbound web hook](https://developer.kustomer.com/docs/events/inbound-webhooks) and use a workflow triggered by it. ### Schedule a workflow The Scheduler app's action runs a callable workflow later. Re-check the state in the callable workflow, as `load` does above, because it might have changed by the time it runs. ```json title="In the caller: run send-followup in 24 hours" { "id": "schedule", "action": "kustomer.app.scheduler.schedule-workflow", "appVersion": "scheduler-^1.0.1", "params": { "workflowId": "6513a0d1a4b1e1001a6b3e01", "runAt": { "type": "relative", "value": 86400 }, "inputs": { "conversationId": "/#steps.1.id", "reason": "No reply after 24 hours" } }, "transitions": [] } ``` | `runAt.type` | `runAt.value` | | ------------ | ----------------------------------------------------------------------------------------------- | | `relative` | Seconds from now, such as `3600` | | `absolute` | An ISO 8601 timestamp, such as `"2026-10-22T14:00:00Z"`, or one computed with the `date` helper | `workflowId`, `runAt` and `inputs` are all required; pass `{}` when there are no inputs. A relative `value` must be a whole number of seconds. When the time comes, the callable workflow runs with those `inputs` as its step `1` output. To keep a scheduled reminder within working hours, compute an absolute time first, or check `/#fn:inBusinessHours` in the callable workflow. See the [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference). > **The date helper counts backwards:** `{{{date 'hours' count=24}}}` is 24 hours **ago**, so it schedules a run in the past. For a time in the future, use a negative count, `{{{date 'hours' count=-24}}}`, or skip the helper and use `"runAt": { "type": "relative", "value": 86400 }`. The step fails with one of these messages when its inputs are wrong: | Message | Cause | | ------------------------------------------------ | ------------------------------------------------------------------------------ | | `runAt type must be one of absolute or relative` | `runAt.type` is missing or misspelled | | `invalid runAt value` | The timestamp doesn't parse, or the relative value isn't whole seconds | | `Payload too large to schedule workflow` | `inputs` is too big. Pass IDs and look the records up in the callable workflow | ## Actions and triggers from apps Apps add triggers and actions with their own names, such as `kustomer.app..`. A step that uses an app's action includes `appVersion`, such as `"scheduler-^1.0.1"`, so the workflow keeps working as the app updates within that major version. See [Apps: workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers). --- # Conditions and variables > How workflow params and conditions get their values. Covers /#steps references, Handlebars templates and escaping, every condition operator and its casting rules, the changes diff, workflow variables for secrets, and error cases. Source: https://developer.kustomer.com/docs/workflows/conditions-and-expressions Every value in a workflow, whether it's a step param, a condition operand or a REST header, is one of four things: | Form | Example | Result | | --------- | ----------------------------------------------------- | ------------------------------------------------------------------ | | Literal | `"done"`, `3`, `true`, `["vip"]` | Used as is | | Reference | `"/#steps.lookup.emails"` | The value at that path, **with its type** (array, number, object…) | | Function | `"/#fn:parseFloat,steps.1.attributes.data.total"` | The function's result, typed | | Template | `"Order {{{steps.1.custom.orderNumberStr}}} shipped"` | A **string**, rendered with Handlebars | The rule: a string that starts with `/#` is a reference or function call and keeps its type, but only when the reference is the _whole_ string. Any other string is a template, so `"Order /#steps.1.id"` is sent as that literal text. To mix text and values, use `{{ }}`. For a guided introduction to this language, see [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables). This page is the reference. After resolution, each param is cast to the type in the action's input schema. A template that renders `"42"` for a number input becomes `42`. ## References `/#` followed by a dotted path into the run's context: | Path | What it is | | ------------------------- | -------------------------------------------------------- | | `steps..` | A step's output. The trigger is step `1` | | `steps..list.0.field` | An array element, by index | | `steps..list.length` | An array's length | | `variables.` | A [workflow variable](#variables-and-secrets), by its ID | | `error.` | The current error, inside [error cases](#handle-errors) | A path that doesn't exist resolves to `undefined`. It doesn't cause an error, so a typo in a step ID silently produces nothing. Check the run's logs if a value seems to be missing. When a param's reference resolves to `null` or `undefined`, the param is left out of the request entirely. An empty object counts as "not set" too. The one exception is `queue`, where `null` is kept and means "unassign". ### Path syntax | Syntax | Example | Notes | | --------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Dots | `steps.lookup.custom.tierStr` | Each segment is an object key | | Index | `steps.lookup.emails.0.email` or `steps.lookup.emails[0].email` | Whole, non-negative numbers only. There are no negative indexes, so you can't count from the end | | `length` | `steps.1.tags.length` | Works on arrays and strings | | Quoted brackets | `steps.call.body["order.id"]` | For a key that contains dots. Plain dots also work if that exact key exists | Only an object's own keys are read, so inherited properties such as `constructor` resolve to nothing. ```json title="References in params" { "id": "followup", "action": "kustomer.conversation.update", "params": { "id": "/#steps.1.id", "assignedUsers": ["/#steps.owner.id"], "custom": { "orderTotalNum": "/#fn:parseFloat,steps.order.data.total", "lastOrderAt": "/#steps.order.createdAt" } }, "transitions": [] } ``` ## Templates Strings without `/#` are [Handlebars](https://handlebarsjs.com/guide/) templates rendered against the same context, so `{{steps.1.name}}`, `{{{variables.}}}` and the standard block helpers (`{{#if}}`, `{{#each}}`, `{{#unless}}`, `{{#with}}`) all work. Kustomer adds [helpers](https://developer.kustomer.com/docs/workflows/expressions-reference#handlebars-helpers) for dates, HMAC signing, JSON and formatting. > **Use triple braces for text:** `{{ … }}` HTML-escapes the value, so `Tom & Jerry's` becomes `Tom & Jerry's`. That's rarely what you want in a note, a URL or a JSON body. Use `{{{ … }}}` to insert the value unchanged. All the helper examples use triple braces for this reason. ```json title="Templates in params" { "body": "{{{steps.lookup.firstName}}} asked about order {{{steps.1.custom.orderNumberStr}}}.{{#if steps.1.custom.vipBool}} VIP customer.{{/if}}", "uri": "https://api.example.com/orders/{{{steps.1.custom.orderNumberStr}}}?expand=items" } ``` A template always produces a string. To pass an array or object, use a reference instead: `"tags": "/#steps.1.tags"`, not `"tags": "{{steps.1.tags}}"`. _A sample run context in JSON, with step 1 as a conversation update trigger and a lookup step holding a customer's first name and emails, next to a list of example expressions. Selecting one traces the keys it walks: /#steps.lookup.emails returns the array itself, /#steps.lookup.emails.0.email returns jane\@example.com, /#steps.1.channels.length returns the number 1, and the typo /#steps.lokup.id stops at steps and resolves to undefined without an error. Two templates render strings: one adds ' VIP customer.' only while vipBool is true, and {{steps.1.name}} HTML-escapes Tom & Jerry's to Tom & Jerry's while triple braces leave it unchanged. Two eq conditions pass or fail as you flip values: changes.attributes.status.after equals done, and priority 4 equals the string 4 because eq casts the right side to the left side's type._ ## Conditions Conditions guard [transitions](https://developer.kustomer.com/docs/workflows#anatomy). Each one is `{ "op": …, "values": […] }`. Each value can be a literal, a reference or a template. ```json title="Single comparison" { "op": "eq", "values": ["/#steps.1.changes.attributes.status.after", "done"] } ``` ```json title="Combined" { "op": "and", "values": [ { "op": "eq", "values": ["/#steps.1.changes.attributes.status.after", "done"] }, { "op": "contains", "values": ["/#steps.1.channels", "email"] }, { "op": "or", "values": [ { "op": "gte", "values": ["/#steps.1.priority", 4] }, { "op": "eq", "values": ["/#steps.lookup.custom.tierStr", "gold"] } ] } ] } ``` ```json title="Always" { "op": "true", "values": [true] } ``` ### Operators | `op` | In the builder | Values | Passes when | | ------------------------ | ------------------------------------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | | `and` | Multiple Conditions (And) | conditions | Every nested condition passes | | `or` | Multiple Conditions (Or) | conditions | Any nested condition passes | | `exists` | Exists | `[a]` | `a` is truthy | | `dne` | Does not exist | `[a]` | `a` is falsy ("does not exist") | | `true` | not offered | `[a]` | `a` is `true` or the string `"true"`. `[true]` makes a branch that always passes | | `eq` | Equals | `[a, b]` | `a` equals `b`, after casting `b` to `a`'s type | | `neq` | Not equal | `[a, b]` | `eq` would fail | | `gt`, `gte`, `lt`, `lte` | Greater than, Greater than or equal, Less than, Less than or equal | `[a, b]` | Numeric comparison of `Number(a)` and `Number(b)` | | `gt-date`, `lt-date` | After, Before | `[a, b]` | `a` is after or before `b`, both parsed as dates | | `contains` | Contains | `[a, b]` | `a` is an array that includes `b`, or a string containing `b` (case-sensitive). If `b` is an array, every item must be contained | | `not-contains` | Does not contain | `[a, b]` | `a` doesn't contain `b`. If `b` is an array, at least one item is missing | There are no `in` or `not-in` operators. To test "status is one of A or B", use an `or` group of `eq` conditions. An `op` that isn't in this table fails the run with `operation not supported`. The builder offers only the operators that suit the field you pick. Text, list, date-time and number fields all get Exists, Does not exist, Equals and Not equal. Text and list fields add Contains and Does not contain, date-time fields add Before and After, and number fields add the four numeric comparisons. Boolean fields get only Equals and Not equal. A path you type yourself in code view gets every operator. The builder never offers `true`; when a step's only transition is always-true, the canvas draws a plain line instead of a condition. > **Casting rules that catch people out** > > - **`exists` is truthiness.** `0`, `false` and `""` don't "exist". Use `eq` to test for zero or false. > - **`neq` passes when the field is missing.** `neq [/#steps.1.custom.tierStr, "gold"]` is true for customers without a tier. Add an `exists` check with `and` if that matters. > - **`eq` casts the right side to the left side's type.** `["/#steps.1.priority", "4"]` compares numbers, but `["4", "/#steps.1.priority"]` compares strings. Put the reference first. > - **A missing left side isn't cast.** If the field is missing, nothing is cast and the comparison is strict, so `eq [, false]` fails. Use `neq true` to catch both `false` and missing. > - **Invalid dates never compare.** If either side of `gt-date` or `lt-date` doesn't parse, the condition fails. > - **For "is false", use `neq true`.** Don't use a `false` operator. > - **A path that doesn't resolve is treated as `""`.** The condition is evaluated against an empty string; the run doesn't fail. `contains` understands skills: on a conversation's `skills` array it matches the skill ID, and only for active skills. ### Case-insensitive text checks `contains` and `eq` are case-sensitive. Condition values can be templates as well as references, so render the left side in lowercase and compare it with a lowercase literal: ```json { "op": "contains", "values": ["{{{toLower steps.1.subject}}}", "refund"] } ``` A template always renders a string, so use this only for text. For numbers and booleans, keep the plain reference so `eq` can cast. ### Conditions on what changed Update triggers fire for _every_ change to the record, including changes your workflow makes. Base conditions on the `changes` diff, which describes only this update: ```json title="changes in a customer.update trigger" { "changes": { "attributes": { "custom": { "op": "replace", "before": { "tierStr": "silver", "loyaltyPointsNum": 1200 }, "after": { "tierStr": "gold", "loyaltyPointsNum": 2400 } }, "modifiedAt": { "op": "replace", "before": "2026-10-21T15:00:00.000Z", "after": "2026-10-21T15:20:42.554Z" } } } } ``` | You want | Condition | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Status just became done | `eq [/#steps.1.changes.attributes.status.after, "done"]` | | Status changed at all | `exists [/#steps.1.changes.attributes.status]` | | Became VIP (custom field) | `and` of `eq [/#steps.1.changes.attributes.custom.after.tierStr, "gold"]` and `neq [/#steps.1.changes.attributes.custom.before.tierStr, "gold"]` | | A tag was added | `contains [/#steps.1.changes.attributes.tags.after, ""]` and `not-contains [/#steps.1.changes.attributes.tags.before, ""]` | | Assigned to someone new | `exists [/#steps.1.changes.attributes.assignedUsers]` | Custom attributes change as one `custom` entry whose `before` and `after` hold the changed custom fields, so the field name comes after `before` or `after` in the path. Tags, users and teams are stored as IDs, so compare with IDs, not names. The full diff format is in [The event envelope](https://developer.kustomer.com/docs/events#the-event-envelope). ## Variables and secrets Workflow variables hold values you don't want written into the workflow itself: API keys, signing secrets, or a URL that differs between your sandbox and production orgs. Each variable belongs to one workflow. ```bash curl -X POST "https://acme.api.kustomerapp.com/v1/workflows/$WORKFLOW_ID/variables" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "analyticsToken", "value": "sk_live_…", "type": "string" }' ``` ```json title="Response (abridged)" { "data": { "id": "6513b2f4a4b1e1001a6b3f20", "attributes": { "name": "analyticsToken" } } } ``` **Variable** **Refer to a variable by its ID**, the 24-character `data.id` returned when you create it, not by its name. Use `/#variables.` as a whole param, or `{{{variables.}}}` inside a template: ```json { "headers": { "Authorization": "Bearer {{{variables.6513b2f4a4b1e1001a6b3f20}}}" } } ``` In the builder, the value picker's **Variables** section inserts `/#variables.` for you, and JSON fields have a **Variables** button that copies `{{{variables.}}}`. To find an ID later, list the workflow's variables with [`GET /v1/workflows/{id}/variables`](https://developer.kustomer.com/api/workflows/get-all-variables-for-a-workflow). > **Names resolve to nothing:** `{{{variables.analyticsToken}}}` looks right but renders an empty string, because variables are keyed by ID. Variables also belong to one workflow, so when you copy a workflow's JSON into another workflow or another org, every variable reference breaks until you create the variables there and update the IDs. Variables are cached, so like workflow edits, a changed value can take about a minute to reach new runs. Manage them with the [variables endpoints](https://developer.kustomer.com/api/workflows/get-all-variables-for-a-workflow). > **Keep secrets out of params:** Anything written directly into a workflow's params is visible to everyone who can view workflows, and is returned by `GET /v1/workflows`. Put keys and tokens in variables. ## Handle errors When a step fails, for example because a Kustomer action rejects its input with a `4xx`, the step's `errorCases` decide what happens. Without a matching case, the run stops and the failure is recorded on the workflow. See [Debugging](https://developer.kustomer.com/docs/workflows/debugging). The classic workflow builder has no editor for error cases. Add `errorCases` in **Edit workflow JSON** or with the [API](https://developer.kustomer.com/docs/workflows/workflow-json#create-and-update-with-the-api). ```json title="Continue past a validation error, stop on anything else" { "id": "create-order", "action": "kustomer.kobject.create-with-customer", "params": { "klassName": "order", "customer": "/#steps.1.customer", "title": "/#steps.1.custom.orderNumberStr", "data": {} }, "errorCases": [ { "condition": { "op": "and", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] }, "errorAction": { "type": "continue" } }, { "condition": { "op": "and", "values": [] }, "errorAction": { "type": "stop-processing" } } ], "transitions": [{ "target": "tag", "condition": { "op": "exists", "values": ["/#steps.create-order.id"] } }] } ``` - Cases are checked in order and the first match applies. A case whose `condition.values` is empty matches every error. - An error case's `condition.op` must be `and` or `or`, with up to five nested conditions, and a step can have up to 10 cases. - While cases are checked, the error is at `/#error`, never at `/#steps..error`. For Kustomer actions it's the first item of the API's `errors` array, with the fields `status`, `code`, `title`, `detail` and `source`. | `errorAction.type` | Effect | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `continue` | Move on to the step's transitions as if it had succeeded, but with no output. Guard the next transition with `exists` on the step's output, as above | | `stop-processing` | End the run quietly. It isn't recorded as a failure | | `retry-workflow` | Behaves exactly like `continue`. Nothing is retried | | `retry-action` | Not handled. The step fails as if no case had matched | Use `continue` and `stop-processing`. The schema accepts `retry-action` and `retry-workflow`, but neither retries anything; branch explicitly instead. > **Not every failure reaches error cases** > > - **A `5xx` from a Kustomer action**, or a temporary platform problem, skips error cases. Kustomer runs the workflow again from step `1`, so steps that already succeeded run again. These retries aren't recorded on the workflow's error log. > - **The run timeout** ends the run whatever its error cases say. It's checked between steps. > - **The [REST actions](https://developer.kustomer.com/docs/workflows/code-steps#handle-responses-and-failures) never fail on a status code**, so check `/#steps..response.statusCode` in a transition instead. ## App settings Workflows that ship in an [app](https://developer.kustomer.com/docs/apps/settings) read the installing org's settings with the `setting` helper, which resolves before the template renders: ```json { "uri": "{{{ setting myapp.default.apiBaseUrl }}}/v1/orders/{{{steps.1.custom.orderNumberStr}}}" } ``` The path is `..`, matching the app definition's `settings`. You can add a default value and a type after the path: `{{{ setting .. }}}`. Kustomer checks `setting` references when you save, and rejects invalid ones with `Invalid app settings configuration passed`. --- # Expressions reference > Complete reference for Kustomer's expression languages. Covers every /#fn function, every Handlebars helper with its arguments and defaults, and the criteria format and operators used by queue rules and satisfaction surveys. Source: https://developer.kustomer.com/docs/workflows/expressions-reference Kustomer has three small expression languages. This page lists everything in each. | Language | Looks like | Used in | | ----------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------- | | [Functions](#functions) | `"/#fn:parseInt,steps.1.custom.qtyStr"` | Workflow params and conditions. Returns typed values | | [Handlebars helpers](#handlebars-helpers) | `"{{{dateFormat steps.1.createdAt format='LL'}}}"` | Any workflow string that isn't a reference. Returns strings | | [Criteria](#criteria) | `{ "and": [{ "conversation_status": { "operator": "equals", "value": "open" } }] }` | Queue rules and satisfaction surveys | For how references, templates and conditions fit together, start with [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions). ## Functions A function call is a reference that starts with `/#fn:`. The parts are separated by commas, with no spaces: the function name, a path to the input value, then any arguments. ```text /#fn:,,, ``` Because commas separate the parts, arguments can't contain commas (`dateFormat`'s format is the one exception). The path is resolved like any reference, so `steps.1.custom.qtyStr` means `/#steps.1.custom.qtyStr`. Write it as a bare path: `/#fn:parseInt,steps.1.custom.qtyStr`, not `/#fn:parseInt,/#steps.1.custom.qtyStr`. Like any reference, a function call only works as the whole value of a param or condition operand. A function name that doesn't exist doesn't fail: it silently returns the input value unchanged, so check spelling carefully. | Function | Example | Returns | | ------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `parseInt` | `/#fn:parseInt,steps.1.custom.qtyStr` | An integer. `0` if the input is empty or not a number | | `parseFloat` | `/#fn:parseFloat,steps.1.attributes.data.total` | A number. `0` if the input is empty or not a number | | `parseBool` | `/#fn:parseBool,steps.1.attributes.data.vip` | `true` for `true` or `"true"` (any case), otherwise `false` | | `parseJSON` | `/#fn:parseJSON,steps.call.body` | The parsed object. `{}` if the string isn't valid JSON | | `dateFormat` | `/#fn:dateFormat,steps.1.createdAt,YYYY-MM-DD` | The date in a [Moment.js format](https://momentjs.com/docs/#/displaying/format/). The default is ISO 8601. `undefined` if the input isn't a date | | `slice` | `/#fn:slice,steps.1.tags,0,3` | Part of an array or string, like JavaScript's `slice(start, end)` | | `map` | `/#fn:map,steps.lookup.emails,email` | Pulls one property out of every item in an array: `["jane@example.com", …]` | | `transform` | `/#fn:transform,steps.call.body.items,name=title,sku=id` | Each array item reshaped: the new key `name` takes the old key `title`, and so on | | `cond` | `/#fn:cond,steps.1.status,open=Waiting,done=Resolved` | The value for the matching key, or `undefined`. Matches strings exactly, so it doesn't work on numbers | | `ifExists` | `/#fn:ifExists,steps.lookup.id,/#steps.lookup.name` | The second expression if the path has a value, otherwise `undefined`, so the param is left out | | `URIEncode` | `/#fn:URIEncode,steps.1.attributes.data.email` | `encodeURIComponent` of the value | | `URIDecode` | `/#fn:URIDecode,steps.1.attributes.data.redirect` | `decodeURIComponent` of the value | | `isValidEmailAddress` | `/#fn:isValidEmailAddress,steps.1.attributes.data.email` | `true` or `false` | | `isValidPhoneNumber` | `/#fn:isValidPhoneNumber,steps.1.attributes.data.phone` | `true` if the number can be formatted as a valid phone number | | `findIncludedResourceAttribute` | `/#fn:findIncludedResourceAttribute,steps.call.body.included,type=order,id=4417,attributes.total` | From a JSON:API `included` array, the property of the first resource matching every `key=value` | | `inBusinessHours` | `/#fn:inBusinessHours,steps.1.createdAt,64f1c0ffee5ca1ab1e0000aa` | Whether the date falls within a [business schedule](https://developer.kustomer.com/api/core-resources/create-a-business-schedule). See below | ### inBusinessHours | Form | Checks | | ------------------------------------------------ | ---------------------------------------------------------------------------- | | `/#fn:inBusinessHours,` | Now, against that schedule | | `/#fn:inBusinessHours,,` | The date at the path, against that schedule | | `/#fn:inBusinessHours,,` | The date, against the schedule ID found at a second path, such as a variable | | `/#fn:inBusinessHours,` | The date, against the org's default schedule | Holidays on the schedule count as closed. If there's no schedule to check against, or the date isn't valid, the result is `true`. ```json title="Route differently out of hours" { "target": "after-hours-reply", "condition": { "op": "neq", "values": ["/#fn:inBusinessHours,steps.1.createdAt,64f1c0ffee5ca1ab1e0000aa", true] } } ``` ## Handlebars helpers Use helpers inside templates, with positional arguments first and then `key=value` options. Wrap them in triple braces, `{{{ }}}`, so the output isn't HTML-escaped. Arguments can be paths (`steps.1.createdAt`), quoted strings or numbers. ### Dates | Helper | Example | Output | | --------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `date` | `{{{date 'hours' count=24}}}` | ISO timestamp 24 hours **ago**. Units: `minutes`, `hours`, `days`, `years`, singular or plural | | `date` (future) | `{{{date 'days' count=-3}}}` | ISO timestamp 3 days **from now**. Use a negative count | | `dateFormat` | `{{{dateFormat steps.1.createdAt format='MMMM Do YYYY, h:mma' timezone='America/New_York'}}}` | `October 21st 2026, 11:20am` | | `dateDiff` | `{{{dateDiff 'hours' from=steps.1.createdAt to=steps.1.lastDone.createdAt}}}` | Whole units from `from` to `to`. Units: `years`, `quarters`, `months`, `weeks`, `days` (default), `hours`, `minutes`, `seconds`, `milliseconds` | `dateFormat` options: `format` (Moment.js, default `LL`, such as "October 21, 2026"), `locale` (default `en-us`), `inputTimezone` and `timezone` (output; IANA names or `UTC`). It accepts ISO strings and Unix timestamps in milliseconds, and outputs an empty string for invalid dates. > **Schedule for later with a negative count:** `date` counts backwards from now. `{{{date 'hours' count=24}}}` is 24 hours **ago**, which is a common mistake when scheduling. To compute an absolute `runAt` for the [Scheduler](https://developer.kustomer.com/docs/workflows/triggers-and-actions#schedule-a-workflow), use a negative count: `"value": "{{{date 'hours' count=-2}}}"` is two hours from now. For a fixed delay, a relative `runAt` in seconds is simpler. ### Signing and encoding | Helper | Example | Output | | -------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `authHMAC` | `{{{authHMAC steps.1.id secretKey=variables.6513b2f4a4b1e1001a6b3f24 algorithm='sha256' encoding='hex'}}}` | HMAC of the first argument. `algorithm`: `sha1`, `sha224`, `sha256` (default), `sha384`, `sha512`. `encoding`: `base64` (default), `hex`, `utf8`, `ascii` | | `base64Encode` | `{{#base64Encode}}{{{variables.6513b2f4a4b1e1001a6b3f22}}}:{{{variables.6513b2f4a4b1e1001a6b3f23}}}{{/base64Encode}}` | Base64 of the block's content. Add `uriEncode="true"` to URL-encode the result | | `base64Decode` | `{{#base64Decode}}{{{steps.1.attributes.data.payload}}}{{/base64Decode}}` | Decoded block content (ASCII) | | `uriEncode` | `{{{uriEncode steps.1.attributes.data.email}}}` | `encodeURIComponent` | | `uriDecode` | `{{{uriDecode steps.1.attributes.data.next}}}` | `decodeURIComponent` | The long hex strings in these examples are [workflow variable](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#variables-and-secrets) IDs. Workflows refer to variables by ID, never by name. See [Call your own code](https://developer.kustomer.com/docs/workflows/code-steps#sign-your-requests) for signing requests to your own services with `authHMAC`. ### JSON and text | Helper | Example | Output | | ---------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `toJSON` | `{{{toJSON steps.lookup.custom}}}` | The value as JSON text | | `parseJSON` | `{{#parseJSON steps.call.body}}{{{order.number}}}{{/parseJSON}}` | Renders the block with the parsed JSON as its context | | `split` | `{{{split steps.1.attributes.data.name separator=' ' index=0}}}` | One part of a split string | | `substring` | `{{{substring steps.1.preview start=0 end=140}}}` | Part of a string | | `replace` | `{{{replace steps.1.preview search='[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}' newvalue='[card]'}}}` | Regex replace. `flags` defaults to `gm` | | `markdownToHTML` | `{{markdownToHTML steps.note.body}}` | Inline HTML from Markdown | | `removeMarkdown` | `{{{removeMarkdown steps.1.preview}}}` | Plain text without Markdown syntax | | `formatCurrency` | `{{{formatCurrency steps.order.data.total currency='EUR' locale='de-DE'}}}` | `1.234,56 €`. Defaults: `USD`, `en-US` | | `math` | `{{{math steps.order.data.total '*' 100}}}` | `+`, `-`, `*`, `/` or `%` on two numbers | | `randomId` | `{{{randomId size=12}}}` | A random ID string. Pass the length as `size` | The string helpers from [Lodash](https://lodash.com/docs#camelCase) are available too, each taking one string: `camelCase`, `capitalize`, `escape`, `escapeRegExp`, `kebabCase`, `lowerCase`, `lowerFirst`, `snakeCase`, `startCase`, `toLower`, `toUpper`, `trim`, `trimEnd`, `trimStart`, `truncate`, `upperCase` and `upperFirst`. Conditions accept templates, so these helpers also make text checks case-insensitive: `{ "op": "contains", "values": ["{{{toLower steps.1.subject}}}", "refund"] }`. See [Case-insensitive text checks](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#case-insensitive-text-checks). ### Logic | Helper | Example | Output | | --------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `or` | `{{{or steps.lookup.firstName 'there'}}}` | The first value if it's truthy, otherwise the second | | `cond` | `{{{cond steps.1.status open='Waiting on us' done='Resolved'}}}` | The option named by the value, or an empty string | | `ifCond` | `{{#ifCond steps.1.priority '>=' 4}}Urgent{{else}}Normal{{/ifCond}}` | Block helper. Operators: `==`, `===`, `!=`, `!==`, `<`, `<=`, `>`, `>=`, `&&`, `\|\|` | | `isEmpty` | `{{#isEmpty steps.1.tags}}Untagged{{else}}Tagged{{/isEmpty}}` | Block helper: empty strings, arrays, objects, `null` and `undefined` are empty | Handlebars' built-ins (`if`, `unless`, `each`, `with`, `lookup`) work as documented at [handlebarsjs.com](https://handlebarsjs.com/guide/builtin-helpers.html). ### App settings In workflows that ship with an [app](https://developer.kustomer.com/docs/apps/settings), `{{{ setting .. }}}` inserts the installing org's value for that setting. Write it with exactly one space inside each set of braces, as shown. To supply a fallback and a type, add them after the path: `{{{ setting .. }}}`. Settings are resolved before the rest of the template renders, and invalid `setting` references are rejected when you save the workflow. ## Criteria Queue rules and satisfaction surveys don't use workflow conditions. They use **criteria**: an object with `and` and `or` lists of field checks. ```json title="Route VIP chats to a queue" { "and": [ { "conversation_channel": { "operator": "in", "value": ["chat"] } }, { "customer_custom_tierStr": { "operator": "equals", "value": "gold" } } ], "or": [ { "conversation_priority": { "operator": "gte", "value": 4 } }, { "conversation_preview": { "operator": "contains_any_of", "value": ["refund", "chargeback"] } } ] } ``` The criteria match when **every** `and` check passes **and** at least one `or` check passes. An empty list doesn't restrict anything, so `{ "and": [], "or": [] }` matches everything. | Where | Endpoint | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Queue rules | [`POST /v1/routing/queue-rules`](https://developer.kustomer.com/api/queues-and-routing/create-queue-rule) | | Fields queue rules accept | [`GET /v1/routing/queue-rules-criteria`](https://developer.kustomer.com/api/queues-and-routing/get-queue-criteria) | | Satisfaction surveys | [`POST /v1/satisfaction`](https://developer.kustomer.com/api/settings-and-configurations/create-a-satisfaction-survey) | ### Field names A field is `_`, where the object is `conversation`, `customer`, `message`, `company` or `kobject`, and the attribute keeps its camelCase name: `customer_lastSeenAt`, `conversation_assignedTeams`. Custom attributes are `_custom_`, such as `conversation_custom_orderNumberStr`. A few names are shortcuts: `conversation_channel` checks `channels`, and `customer_email` and `customer_phone` check the primary email and phone. The queue rules criteria endpoint lists every field you can use, with its type and an `operator` group (`string`, `number`, `datetime`, `boolean`, `list`) that tells you which operators make sense. > **Not the same as search:** Criteria field names look like [search](https://developer.kustomer.com/docs/concepts/search) attribute names but aren't the same. Search uses names such as `customer_last_seen_at`, while criteria use the attribute's camelCase name. Don't copy one into the other. ### Operators | Operator | `value` | Passes when | | ------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `is_set` | none | The field has a value. `false` and `0` count as set; empty strings and empty arrays don't | | `not_set` | none | The field is missing, `null`, an empty string or an empty array | | `equals` | any | Equal after casting the value to the field's type. For an array field, the array includes the value | | `not_equals` | any | `equals` would fail | | `gt`, `gte`, `lt`, `lte` | number or date | Numeric comparison, or a date comparison if the value is a date. See relative dates below | | `in` | array | The field's value is in the list. For an array field, at least one item is | | `not_in` | array | `in` would fail | | `contains` | string or array | The field contains the text, ignoring case. For an array field, an item does. With an array value, every item must be contained | | `not_contains` | string or array | `contains` would fail | | `contains_any_of` | array | The field contains at least one of the strings, ignoring case | | `not_contains_any_of` | array | It contains none of them | | `matches` | regex string | The field matches the regular expression (case-sensitive) | | `in_business_hours` | schedule ID | The date field falls within the business schedule | | `not_in_business_hours` | schedule ID | It doesn't | | `changes` | `before`, `after` | The field changed in this update, and its old and new values match the nested checks | Date comparisons accept ISO 8601 timestamps or relative values: `now-2d` means two days ago and `now+1h` an hour from now. The units are `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks) and `M` (months), with up to 99 of each. ```json title="Last seen more than a week ago" { "customer_lastSeenAt": { "operator": "lt", "value": "now-7d" } } ``` ```json title="Status changed from open to done" { "conversation_status": { "operator": "changes", "before": { "operator": "equals", "value": "open" }, "after": { "operator": "equals", "value": "done" } } } ``` A check on a custom attribute that has since been deleted is ignored, so it passes. Remove such checks when you delete attributes. --- # Call your own code > Run your own logic from a Kustomer workflow with REST steps. Covers building requests, authentication and signing, branching on status codes, network retries, URL restrictions, timeouts, long-running work, and getting results back into Kustomer. Source: https://developer.kustomer.com/docs/workflows/code-steps Workflows don't run arbitrary code. When you need logic the built-in actions can't express, such as pricing rules, a lookup in your database or a call to a partner API, put it in a small HTTP service you own and call it with a **REST step**. Your service can use any language, libraries and data. The workflow passes it the context it needs and branches on what it returns. ```mermaid sequenceDiagram autonumber participant W as Workflow participant S as Your service participant K as Kustomer API W->>S: POST /kustomer/refund-eligibility (JSON + auth header) S->>S: Your logic, your database opt Need more context S->>K: GET /v1/customers/{id} end S-->>W: 200 { "eligible": true, "maxAmount": 40 } W->>W: Branch on /#steps.check.body.eligible ``` ## Make the request ```json title="REST step" { "id": "check", "action": "kustomer.rest-api.json", "params": { "method": "post", "uri": "https://hooks.example.com/kustomer/refund-eligibility", "headers": { "Content-Type": "application/json", "Authorization": "Bearer {{{variables.6513b2f4a4b1e1001a6b3f21}}}" }, "data": { "conversationId": "/#steps.1.id", "customerId": "/#steps.1.customer", "orderNumber": "/#steps.1.custom.orderNumberStr", "channel": "/#steps.1.channels.0" }, "json": true, "allowNetworkRetry": true }, "transitions": [ { "target": "approve", "condition": { "op": "and", "values": [ { "op": "eq", "values": ["/#steps.check.response.statusCode", 200] }, { "op": "true", "values": ["/#steps.check.body.eligible"] } ] } }, { "target": "escalate", "condition": { "op": "true", "values": [true] } } ] } ``` **Params** Use `kustomer.rest-api.json` for almost everything, and always set `"json": true` when you expect JSON back, so later steps can reference fields like `/#steps.check.body.eligible`. `6513b2f4a4b1e1001a6b3f21` in the `Authorization` header is the ID of a [workflow variable](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#variables-and-secrets) holding the token. Workflows refer to variables by ID, not by name; the builder's variable picker inserts the ID for you. > **Use Kustomer actions for Kustomer data:** Don't call the Kustomer API from a REST step. The built-in [actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions#built-in-actions) are already authenticated, return flattened output, and handle not-found for you. Call the Kustomer API from your service instead, where you can use a scoped API key. ## Authenticate Your endpoint is on the public internet, so it must reject requests that don't come from your workflow. Store the credential in a [workflow variable](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#variables-and-secrets), never in params. **Bearer token** The simplest option, and enough over HTTPS: ```json { "headers": { "Authorization": "Bearer {{{variables.6513b2f4a4b1e1001a6b3f21}}}" } } ``` ```ts title="Express" import { timingSafeEqual } from 'node:crypto' function fromKustomer(req: Request) { const expected = Buffer.from(`Bearer ${process.env.KUSTOMER_WORKFLOW_TOKEN}`) const actual = Buffer.from(req.get('authorization') ?? '') return actual.length === expected.length && timingSafeEqual(actual, expected) } ``` **Basic auth** For services that expect HTTP Basic, encode the pair with the `base64Encode` block helper. Here the user name and password are two variables, referenced by their IDs: ```json { "headers": { "Authorization": "Basic {{#base64Encode}}{{{variables.6513b2f4a4b1e1001a6b3f22}}}:{{{variables.6513b2f4a4b1e1001a6b3f23}}}{{/base64Encode}}" } } ``` ### Sign your requests When a partner requires a signature, compute an HMAC with the `authHMAC` helper. Sign a value the receiver can reconstruct, such as the ID of the record the request is about: ```json { "headers": { "Content-Type": "application/json", "X-Conversation-Id": "{{{steps.1.id}}}", "X-Signature": "{{{authHMAC steps.1.id secretKey=variables.6513b2f4a4b1e1001a6b3f24 algorithm='sha256' encoding='hex'}}}" } } ``` ```python title="Verify (Python)" import hashlib, hmac, os def valid(request) -> bool: conversation_id = request.headers["X-Conversation-Id"] expected = hmac.new(os.environ["KUSTOMER_SIGNING_KEY"].encode(), conversation_id.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, request.headers.get("X-Signature", "")) ``` The step serializes `data` itself, so a template can't sign the exact body bytes. Treat the signed ID as the trustworthy part, and if the body drives anything important, re-read the record from the Kustomer API before acting on it. ## Handle responses and failures The REST step **doesn't fail on HTTP status codes**. A `404` or `503` from your service is a successful step whose output says what happened: ```json title="Step output" { "response": { "statusCode": 503, "headers": { "content-type": "text/plain", "retry-after": "30" } }, "body": "Service Unavailable" } ``` So every REST step needs transitions for the outcomes you care about, as in the example above, with a final always-true branch to catch everything else. | Reference | Value | | -------------------------------------- | ------------------------------------------------------------------------------- | | `/#steps..response.statusCode` | The HTTP status, as a number | | `/#steps..response.headers.` | A response header. Names are lowercase | | `/#steps..body` | The body: an object with `"json": true` and a JSON response, otherwise a string | | `/#steps..body.` | A field of a JSON body | ### Network errors When the request doesn't produce a response at all (DNS failure, connection refused or reset, or no response within **60 seconds**), the step fails and the run stops with an error. Don't count on [error cases](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#handle-errors) to recover from a connection failure: they're designed around Kustomer action errors, where `/#error` is the API's error object. Plan for the run to end at that step. Network retries are off by default. With `"allowNetworkRetry": true`, the step first retries connection resets, refusals and timeouts: up to **3 attempts** in total, 5 and then 10 seconds apart. Only enable it if your endpoint is safe to call twice, because a timed-out request may have reached your service. Use an idempotency key such as the conversation ID. ### Calls can repeat Your endpoint can receive the same request more than once even without `allowNetworkRetry`. If a _later_ Kustomer action in the same run gets a `5xx` from the API, Kustomer runs the whole workflow again from step `1`, and your REST step sends its request again. Make your endpoint idempotent: key the work on a stable value such as the conversation ID plus what happened, and return the earlier result for a repeat. ## Where you can send requests The URL must use `http` or `https` and resolve to a **public IPv4 address**. The step rejects: - Private, loopback, link-local, carrier-grade NAT, multicast, reserved and documentation ranges (`10.0.0.0/8`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10` and others) - IPv6 literals such as `https://[2001:db8::1]/` - Hostnames that don't resolve Expose services in a private network through a public gateway or reverse proxy with authentication. Use `https` for anything that carries customer data or credentials. ## Keep it fast A REST step waits up to 60 seconds, and the whole run must finish within [5 minutes](https://developer.kustomer.com/docs/workflows#the-life-of-a-run). The run limit is checked between steps, so the run ends after the step that crosses it. For anything slower, such as an LLM call, a batch job or a human approval, reply immediately and finish asynchronously: ```mermaid sequenceDiagram participant W as Workflow participant S as Your service participant H as Inbound web hook participant W2 as Second workflow W->>S: POST /jobs { conversationId } S-->>W: 202 { "jobId": "j_81" } Note over S: Work for as long as it takes S->>H: POST { jobId, conversationId, result } H->>W2: kustomer.hooks.web..receive W2->>W2: Update the conversation with the result ``` Your service can also write results directly with the Kustomer API, for example by [adding a note](https://developer.kustomer.com/api/core-resources/create-note-within-conversation) or updating custom attributes. Posting to an [inbound web hook](https://developer.kustomer.com/docs/events/inbound-webhooks) instead keeps the "what to do with the result" logic in a workflow your admins can see and change. ## Run your code from outside a workflow The reverse direction, your system starting a workflow, also uses an inbound web hook. Workflows can't be called directly over REST. Create a web hook, use it as the trigger of a workflow, and post to it from your code: ```bash curl -X POST "$KUSTOMER_HOOK_URL" \ -H "Authorization: Bearer $KUSTOMER_HOOK_KEY" -H "Content-Type: application/json" \ -d '{ "conversationId": "6512f0c2a4b1e1001a6b2c90", "action": "escalate" }' ``` ## In apps An [app](https://developer.kustomer.com/docs/apps/workflows-actions-triggers) can package a REST call as a named action with its own inputs, so admins drag "Look up order" into a workflow instead of configuring a URL. App actions can read the installing org's settings, such as an API base URL or key, and can authenticate with an app account using OAuth 2.0 or a custom header, so the credential never appears in a workflow. ## Checklist - [ ] Endpoint is HTTPS, public, and rejects unauthenticated requests - [ ] Credentials are in workflow variables, not params - [ ] `Content-Type` header set; `"json": true` for JSON responses - [ ] Variables referenced by ID, such as `{{{variables.6513b2f4a4b1e1001a6b3f21}}}` - [ ] Transitions for success, expected failures and a catch-all - [ ] Acceptable behavior when a network failure ends the run - [ ] Endpoint responds in well under 60 seconds and is idempotent, because the run can repeat --- # Workflow JSON > The complete workflow document schema, field by field, with limits and patterns. Covers root keys and flat meta, triggers (event and callable), steps, transitions and the save-time loop check, error cases, the replaces field for overriding app workflows, and creating and partially updating workflows through the API. Source: https://developer.kustomer.com/docs/workflows/workflow-json Everything you build in the workflow builder is stored as a JSON document. Reading and writing that JSON lets you review workflow changes in pull requests, copy workflows between a sandbox and production, generate workflows with code or an AI agent, and ship them inside [apps](https://developer.kustomer.com/docs/apps/workflows-actions-triggers). This page is the field-by-field reference. For a step-by-step tutorial, see [Write workflow JSON](https://developer.kustomer.com/docs/workflows/write-workflow-json). ```json title="A complete workflow" { "name": "vip-first-reply", "description": "Raise priority and tag new conversations from gold-tier customers", "enabled": true, "meta": { "displayName": "VIP first reply" }, "trigger": { "id": "1", "eventName": "kustomer.conversation.create", "transitions": [ { "target": "customer", "condition": { "op": "true", "values": [true] } } ] }, "steps": [ { "id": "customer", "action": "kustomer.customer.find", "params": { "identifier": "/#steps.1.customer" }, "meta": { "displayName": "Look up the customer" }, "transitions": [ { "target": "raise", "condition": { "op": "eq", "values": ["/#steps.customer.custom.tierStr", "gold"] }, "meta": { "name": "Gold tier" } } ], "errorCases": [] }, { "id": "raise", "action": "kustomer.conversation.update", "params": { "id": "/#steps.1.id", "priority": 4 }, "meta": { "displayName": "Raise priority" }, "transitions": [ { "target": "tag", "condition": { "op": "true", "values": [true] } } ] }, { "id": "tag", "action": "kustomer.conversation.append-tags", "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000002"] }, "meta": { "displayName": "Tag VIP" }, "transitions": [] } ] } ``` ## Root No other root keys are allowed. Unknown keys are rejected. Root `meta` must be flat: | Rule | Limit | | ------- | ---------------------------------------------------------------------------------- | | Keys | Match `^[a-zA-Z][a-zA-Z0-9-_]{1,64}$`: a letter, then letters, numbers, `-` or `_` | | Values | Strings of up to 1,024 characters, numbers, booleans or `null` | | Size | Up to 100 keys | | Nesting | Not allowed. An object or array value is rejected | ## Trigger ### Event trigger ```json { "id": "1", "eventName": "kustomer.conversation.update", "transitions": [ … ] } ``` ### Callable trigger ```json { "id": "1", "callable": true, "schema": { "type": "object", "properties": { … } }, "transitions": [ … ] } ``` All four keys are required; a callable trigger without one is rejected when you save. Declare the workflow's outputs in the root [`response`](#root) key. How callers run callable workflows is covered in [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows). ## Steps Only `id` and `transitions` are required. The numbers on the builder's canvas (1, 2, 3…) show each step's position, not its ID. Only the trigger's ID is always `1`; use **Copy Step ID** in the builder to get any other step's ID. ## Transitions ```json { "target": "raise", "condition": { "op": "eq", "values": ["/#steps.customer.custom.tierStr", "gold"] }, "meta": { "name": "Gold tier" } } ``` The first transition whose condition passes wins. If none pass, the run ends successfully. In the builder, a step's branches sit side by side, and their left-to-right order is the order of `transitions`. Kustomer checks the transitions when you save: - **No loops.** A transition can't target a step that's already on the path leading to it. Kustomer rejects the save with `400`: `Step with ID transitions to a previous step with ID :`. The builder shows the same problem on the canvas as "Cycle detected". To repeat work later, schedule a [callable workflow](https://developer.kustomer.com/docs/workflows/triggers-and-actions#schedule-a-workflow). - **Unique transitions.** Two identical transitions on one step are rejected. - **At most 10** transitions per step. To keep a workflow readable in the builder, give each branch its own final step rather than pointing two branches at the same step. Kustomer's own templates do this. ## Error cases ```json { "condition": { "op": "and", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] }, "errorAction": { "type": "continue" } } ``` Cases are checked in order, and the first match applies. A step can have up to 10. ## Read-only attributes The API returns these alongside your fields. Don't send them back. | Attribute | Meaning | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lastError` | The most recent failure, or `{}` | | `editable` | Whether the workflow can be edited in the builder | | `containsVariables` | Whether the trigger or steps reference a [variable](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#variables-and-secrets) (`/#variables.` or `{{variables.`). Variables are loaded for a run only when it's `true` | | `createdAt`, `updatedAt` | Timestamps | ## Create and update with the API #### Create ```bash curl -X POST "https://acme.api.kustomerapp.com/v1/workflows" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \ --data @vip-first-reply.json ``` The response is the workflow with its `id`. Create it with `"enabled": false`, add any [variables](https://developer.kustomer.com/api/workflows/create-a-workflow-variable-for-a-workflow) it needs, then reference them by the IDs the variables endpoint returns, and enable it. #### Update `PUT /v1/workflows/{id}` updates only the root keys you send. Each key you send replaces the stored value wholesale (the whole `steps` array, for example), and keys you leave out are unchanged. Always send `trigger` and `steps` together, because transitions and steps refer to each other. ```bash kusty api GET /v1/workflows/$ID --output json | jq '.data.attributes | {trigger, steps} | del(.steps[]._id)' > wf.json # edit wf.json kusty api PUT /v1/workflows/$ID --data @wf.json --dry-run kusty api PUT /v1/workflows/$ID --data @wf.json ``` For a callable workflow, include `response` too if its outputs change. To switch a workflow on or off, send only `enabled`, as the builder's switch does: ```bash kusty api PUT /v1/workflows/$ID --data '{ "enabled": true }' ``` Each save creates a new version, and Kustomer keeps the 30 most recent. Workflows installed by an app can't be edited (`409`), but you can still turn them on or off and change `logging`. #### Wait, then test Allow about a minute for the change to apply, then cause the trigger event and check the [logs](https://developer.kustomer.com/docs/workflows/debugging). > **Copying between orgs:** Workflows reference org-specific IDs: tags, teams, users, queues, Klass names, business schedules, other workflows and workflow variables. Before you create a copy in another org, replace every ID with the target org's equivalent. Search the JSON for 24-character hex strings to find them. Variables belong to one workflow, so create them on the copy first, then replace each `/#variables.` and `{{{variables.}}}` with the new IDs. The same applies when you copy JSON between two workflows in one org. ### Edit workflow JSON, Export JSON and Duplicate The builder has three ways to get at the JSON. They don't all include every root key: | Feature | What it covers | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Edit workflow JSON** (builder options menu) | Shows `description`, `name`, `steps` and `trigger` from the **last saved version**, so save canvas edits first. **Update** saves the JSON straight to the workflow, and the server validates it | | **Export JSON** (workflows list) | Downloads `description`, `name`, `meta`, `steps` and `trigger`, with `name` changed to `export---` | | **Duplicate** (workflows list) | Creates a copy with the same keys, named `copy-` | Export JSON and Duplicate leave out `response` (a callable workflow's outputs), `enabled` and `logging`, so copies start disabled and callable copies need their **Output Parameters** added again. Both also remove `trigger.eventName` from callable workflows; Kustomer sets a new one when the copy is saved. To keep a complete copy, use `GET /v1/workflows/{id}` instead. ## Override an app workflow with replaces To change a workflow an app installed without editing the app's version, copy it, edit the copy, and set the copy's `replaces` to the original's ID. When both match an event, only the copy runs; the original is skipped. ```json { "name": "shopify-order-sync-custom", "replaces": "6513a0d1a4b1e1001a6b3e77", "enabled": true, "trigger": { … }, "steps": [ … ] } ``` Disable or delete the copy to restore the original behavior. Keep `appVersion` on copied steps that use the app's actions. ## Validate before you save The API validates documents against the schema and rejects invalid ones with a `400` validation error. Common causes: | Error mentions | Fix | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` pattern | Use only letters, numbers, `-`, `_` and `.`; no spaces | | `409` on create or update | Another workflow in the org already has this `name` (names are compared lowercased), or the workflow was installed by an app and can't be edited | | `additionalProperties` | Remove keys the schema doesn't allow, such as read-only attributes copied from a `GET` | | `meta` | Root `meta` must be flat: no nested objects, keys that start with a letter, string values of up to 1,024 characters, and at most 100 keys | | `target` pattern | Step IDs used as targets can't contain dots | | `maxItems` | More than 10 transitions or error cases on one step, or more than 5 conditions in an error case. Split the logic | | `uniqueItems` | Two identical transitions on one step. Remove the duplicate | | `transitions to a previous step` | A transition loops back to a step already on its path. Restructure, or schedule a callable workflow to repeat work | | `Invalid app settings configuration passed` | A `{{{ setting … }}}` reference doesn't match the app's settings | | `appVersion` pattern | Use `-`, such as `kustomer-^1.8.16` | In the builder, the on/off switch also refuses to enable a workflow while any condition lacks a target: "All conditions in active workflows must point to a target". Saving an enabled workflow runs the same check. > **Generating workflows:** Agents generating workflow JSON should: use only actions and triggers from [this catalog](https://developer.kustomer.com/docs/workflows/triggers-and-actions) or the org's installed apps; reference the trigger as `/#steps.1`; refer to variables by ID (`/#variables.`); guard update triggers with a `changes` condition; never point a transition back at an earlier step; keep root `meta` flat; end every branch deliberately; and create disabled first. When updating, send `trigger` and `steps` together. The [create workflow reference](https://developer.kustomer.com/api/workflows/create-a-workflow) has the full request schema, and [Patterns](https://developer.kustomer.com/docs/workflows/patterns) has proven examples. --- # Debugging and logs > Find out why a workflow did or didn't run. Covers live logs in the builder, failed-run records from the processes API, reading their context, the most common error messages, and a checklist for workflows that silently do nothing. Source: https://developer.kustomer.com/docs/workflows/debugging A workflow that misbehaves either **fails** (a step throws, and Kustomer records the error) or **does nothing** (no error, but no effect either). Failures are easy to find. Doing nothing usually means a condition didn't match, and the logs show you which one. For how a run moves from step to step, see [How workflows run](https://developer.kustomer.com/docs/workflows/how-workflows-run). | Tool | Shows | Retention | | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | **View Logs** in the builder | Every run, step by step, with each step's input and output | Records for one hour after you turn on **Debug Mode**. Shows the 100 most recent runs | | **View errors** in the builder, or **View Error Logs** on the Workflows list | Failed runs: "Failed with Error at step …". System retries aren't listed | Recent failures. **Clear All Errors** empties the list | | [`GET /v1/workflows/{id}/processes`](https://developer.kustomer.com/api/workflows/get-processes-errors-by-workflow) | Failed runs, with the triggering event and the full context | Paginated history | | `lastError` on [`GET /v1/workflows/{id}`](https://developer.kustomer.com/api/workflows/get-a-workflow-based-on-the-id) | The most recent failure | Latest only | | [Inbound hook debug logs](https://developer.kustomer.com/docs/events/inbound-webhooks#debug-a-hook) | What a hook received | 24 hours | ## Watch runs live #### Open the logger Open the workflow and select **View Logs** to open **Workflow Log Events**. Turn on **Debug Mode**. Kustomer records the workflow's runs for the next hour, which sets `logging: true` on the workflow. #### Cause the trigger Do the thing that should start the workflow, such as updating a test conversation. Remember that a workflow you just saved can take about a minute to start matching. #### Refresh and inspect Refresh the logger. Each run is listed as a log event with when it completed, how long it took and its last step. Open a run to see every logged step, then select a step to see its input and output. If nothing ran, the logger shows "No applicable logs". _Figure: The workflow builder with the View Logs panel open. The left side lists recent runs with green and red status dots. The selected run shows the trigger and two action steps, and the right panel shows the selected step's input and output JSON. Each step's output is exactly what /#steps.\ refers to._ A step's output in the logger is what `/#steps.` resolves to in later steps. If a param is coming out empty, compare its path with the output of the step it references. A misspelled field or the wrong step ID is the most common cause. Remember that the numbers on the canvas are positions, not step IDs. Each logged run records, for every step, its input, its output, whether it succeeded and when it ran, plus the workflow version that ran. Secrets that Kustomer adds to a step's input are removed from the log. A param that's missing from a step's input usually means its reference resolved to `null` or `undefined`, so the param wasn't sent. ## Read failed runs with the API Every failed run is stored as a _process_. List a workflow's failures, newest first, optionally filtered by date: ```bash kusty api GET /v1/workflows/$WORKFLOW_ID/processes --query createdAtFrom=2026-10-20T00:00:00Z --query pageSize=20 --output json ``` ```json title="One failed run (abridged)" { "type": "workflowProcess", "id": "5eea124bccb9de001933f317", "attributes": { "status": "failed", "createdAt": "2026-10-21T12:53:30.684Z", "error": "{\"message\":\"Invalid regular expression: /*/: Nothing to repeat\",\"step\":\"Se73DhAs5\"}", "event": { "name": "kustomer.customer.update", "data": { "type": "customer", "id": "5e83c3a4d95b7e0012dc9711", "attributes": { … } } }, "meta": { "context": "{\"order\":[\"1\",\"tNG_FZugW\",\"Se73DhAs5\"],\"steps\":{\"1\":{\"name\":\"kustomer.customer.update\",\"output\":{…}},…}}", "attempt": 1, "retry": false, "workflowRev": 27 } } } ``` **Process attributes** `error` and `meta.context` are strings, so parse them before reading. This script prints a one-line summary per failure: ```ts title="failures.ts" const base = 'https://acme.api.kustomerapp.com' const res = await fetch(`${base}/v1/workflows/${process.env.WORKFLOW_ID}/processes?pageSize=50`, { headers: { Authorization: `Bearer ${process.env.KUSTOMER_API_KEY}` }, }) const { data } = await res.json() for (const p of data) { const error = JSON.parse(p.attributes.error ?? '{}') const context = JSON.parse(p.attributes.meta?.context ?? '{}') console.log( p.attributes.createdAt, `rev ${p.attributes.meta?.workflowRev}`, `${p.attributes.event?.name} ${p.attributes.event?.data?.id}`, `path ${(context.order ?? []).join(' → ')}`, `failed at ${error.step}: ${error.message}`, ) } ``` ```bash title="jq" kusty api GET /v1/workflows/$WORKFLOW_ID/processes --output json \ | jq -r '.data[].attributes | [.createdAt, (.error | fromjson | "\(.step): \(.message)"), (.meta.context | fromjson | .order | join(" → "))] | @tsv' ``` ## Common errors | Error message | Cause and fix | | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `next step not found` | A transition's `target` doesn't match any step `id`. Check for renamed or deleted steps | | `operation not supported` | A condition uses an `op` that doesn't exist, such as `in`. See [operators](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#operators) | | `trigger not found` | The trigger's event isn't available, for example because the app that provides it was uninstalled | | `Missing param uri` or `Missing param method` | A REST step's URL or method resolved to nothing. Usually a reference to a field that doesn't exist | | `Missing param regex` or `Missing param testString` | A regex step's pattern or input resolved to nothing | | `runAt type must be one of absolute or relative`, `invalid runAt value` | Fix the Schedule a Workflow step's `runAt`. See [Schedule a workflow](https://developer.kustomer.com/docs/workflows/triggers-and-actions#schedule-a-workflow) | | `Payload too large to schedule workflow` | The scheduled workflow's `inputs` are too big. Pass IDs instead of whole records | | `Method must be one of post, get, patch, put, or delete` | Fix the REST step's `method` | | `Invalid url protocol`, `DNS failure for …`, `Bad IP address for …`, `IPv6 hostname … not supported` | The URL breaks the [REST step's rules](https://developer.kustomer.com/docs/workflows/code-steps#where-you-can-send-requests): it isn't `http`/`https`, doesn't resolve, or points at a private or reserved address | | `Unescaped characters in request URL` | A value with spaces or special characters went into a URL. Wrap it with `{{{uriEncode …}}}` | | `Invalid regular expression` | Fix the regex step's pattern. Remember to escape backslashes in JSON (`"\\d+"`) | | A Kustomer API error, such as a validation `title` and `detail` | A Kustomer action rejected its input: a missing required field, the wrong type, or an ID that belongs to another kind of record. The request is sent even when a required input is missing or empty (`0`, `false` and `""` count as empty). Compare the step's input in the logs with the action's inputs | REST steps that get an error _status_ from your service don't appear here: they succeed, and your transitions handle the status. See [Handle responses and failures](https://developer.kustomer.com/docs/workflows/code-steps#handle-responses-and-failures). ### Errors when you save Some problems are caught when you save rather than when the workflow runs. The most common is a loop: `Step with ID transitions to a previous step with ID :`, shown on the builder's canvas as "Cycle detected". Others include a duplicate `name` (`409`), nested root `meta`, and duplicate transitions. The full list is in [Validate before you save](https://developer.kustomer.com/docs/workflows/workflow-json#validate-before-you-save). ## When something runs twice A run can repeat work for two reasons: - **A system retry.** When a Kustomer action gets a `5xx`, or the platform has a temporary problem, the run starts again from step `1`. Every step before the failure runs again, including creates, notes and REST calls. Make those steps safe to repeat: look up before you create, prefer **Add Tags** over replacing a tag list, and make your own endpoints idempotent. These retries don't appear in **View errors**. - **The workflow triggered itself.** An update trigger fires again for the workflow's own change. Guard it with a condition on `changes`, as described in [Workflows](https://developer.kustomer.com/docs/workflows#the-life-of-a-run). ## When nothing happens Work down this list. Most "it didn't run" reports stop at one of the first four items. - [ ] **Enabled?** Check the toggle or `enabled` in the JSON. - [ ] **Waited a minute?** New and changed workflows take about 60 seconds to start matching. - [ ] **Right trigger?** A new message fires `kustomer.message.create` and also `kustomer.conversation.update`. Make sure you picked the event that actually happens. - [ ] **First condition matched?** Open the run in View Logs. A run that stops after the trigger means no trigger transition matched. Look at step `1`'s output and compare it with the condition, paying attention to types (`"4"` vs `4`), IDs vs names (tags and users are IDs), and `changes` paths. - [ ] **Branch order?** The first matching transition wins. A broad condition before a specific one swallows its runs. In the builder, that's the leftmost branch. - [ ] **Empty values?** A param or condition that comes out empty often references a variable by name instead of ID (`{{{variables.apiKey}}}` renders nothing), uses a step's position number instead of its ID, or reads callable inputs at `/#steps.1.inputs.` instead of `/#steps.1.`. - [ ] **Saved, or only edited?** Canvas changes don't apply until you **Save**, and **Edit workflow JSON** shows the last saved version. Changes then take about a minute to reach new runs. - [ ] **Replaced?** Another enabled workflow with [`replaces`](https://developer.kustomer.com/docs/workflows/workflow-json#override-an-app-workflow-with-replaces) set to this workflow's ID stops it running. - [ ] **Rate limited?** Events over your org's workflow limit are skipped, not queued. Look for bursts, such as bulk imports. - [ ] **Did it run and then get undone?** Another workflow or an integration may be overwriting the change. Check the record's timeline. ## Test safely - **Gate on a test marker.** While building, make the trigger's first condition require a tag such as `wf-test` (`contains [/#steps.1.tags, ""]`). Only your test conversations run the workflow. Remove the gate when you go live. - **Use a sandbox org** for anything that writes data, sends requests to partners or could loop. - **Watch for loops.** A run count that climbs without new activity means the workflow is triggering itself. Disable it, then add a `changes` guard as described in [Workflows](https://developer.kustomer.com/docs/workflows#the-life-of-a-run). - **Version your JSON.** Keep workflow JSON in source control so you can diff a revision that broke something against the last good one. Save it from [`GET /v1/workflows/{id}`](https://developer.kustomer.com/api/workflows/get-a-workflow-based-on-the-id) rather than **Export JSON**, which leaves out `response`, `enabled` and `logging`. `meta.workflowRev` on each failure tells you which revision ran, and the builder's version picker opens the 30 most recent versions read-only. - **Add error cases in JSON.** The classic builder has no error-case editor. Use **Edit workflow JSON** or the API, as described in [Handle errors](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#handle-errors). --- # Apps platform > Package custom objects, UI, automations, settings and integrations into one JSON app definition that any Kustomer org can install, configure and update. Source: https://developer.kustomer.com/docs/apps A Kustomer **app** is a JSON document, the **app definition**, that declares everything an integration needs: custom objects and their attributes, UI inside the agent desktop, workflows and the actions and triggers they use, settings for admins, OAuth connections, and webhooks. You register the definition once. Each org that installs it gets every component created in their account, configured with their own settings, and updated when they choose to move to a new version. You don't host anything for most apps. Kustomer stores and runs the KViews, workflows, actions, hooks and settings pages. You only host code when you want your own UI in an iframe or your own API behind an action. _An app definition on the left with top-level keys app, settings, auth, klasses, kviews, widgets, hooks, triggers, actions, workflows, commands, outboundWebhooks, i18n and changelog. Selecting a key highlights where it appears in a mock Kustomer desktop: App Directory listing, settings page, navigation-rail widget, timeline object, insight card and details, or the behind-the-scenes workflows, hook URL, webhooks and commands._ ## What you can build | You want to | Use | Guide | | ------------------------------------------------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Store orders, subscriptions, devices or bookings next to customers | `klasses` | [Klasses](https://developer.kustomer.com/docs/apps/klasses) | | Add your fields to customers, companies or conversations | `klasses` with `metadata.standard` | [Klasses](https://developer.kustomer.com/docs/apps/klasses#add-attributes-to-standard-objects) | | Show your data in the timeline or the insights panel | `kviews` | [KViews](https://developer.kustomer.com/docs/apps/kviews) | | Embed your own hosted UI next to the agent | `kviews` with `DynamicCard`, or `widgets` | [KViews](https://developer.kustomer.com/docs/apps/kviews#embed-your-own-ui), [Widgets](https://developer.kustomer.com/docs/apps/widgets) | | Accept data from your platform and act on it | `hooks`, `triggers`, `workflows` | [Workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers) | | Let admins call your API from any workflow | `actions` | [Actions](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#actions-call-your-api) | | Call your API from your UI without exposing a key | `commands` | [Commands](https://developer.kustomer.com/docs/apps/kviews#call-apis-with-commands) | | Ask each org for an API key, subdomain or preference | `settings`, `settingsPageConfig` | [Settings](https://developer.kustomer.com/docs/apps/settings) | | Connect to a third-party API with the admin's OAuth grant | `auth.oauth2` | [OAuth for apps](https://developer.kustomer.com/docs/apps/oauth#call-third-party-apis-with-oauth) | | Let your hosted service call the Kustomer API as a user | `redirectUris` and the OAuth provider | [OAuth for apps](https://developer.kustomer.com/docs/apps/oauth#call-kustomer-as-a-user) | | Receive Kustomer events in your service from every install | `outboundWebhooks` | [Outbound webhooks](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#outbound-webhooks) | ## Private and public apps **Private** A private app is registered by your org and can only be installed by your org. It's the right choice for internal tooling, for one customer's custom integration, and for developing and testing any app. When you register a private app, Kustomer appends your org ID to its name to keep it globally unique: `acme_orders` becomes `acme_orders_`. Component names are rewritten to match. See [Register and install](https://developer.kustomer.com/docs/apps/register-and-install#names-are-namespaced). **Public** A public app appears in every org's App Directory. You build and test it as a private app, then submit the definition to Kustomer for review. Kustomer publishes the approved version under its plain name, without an org suffix. The API you register private apps with only accepts `"visibility": "private"`. See [Publish to the marketplace](https://developer.kustomer.com/docs/apps/marketplace). ## The lifecycle ```mermaid sequenceDiagram autonumber participant Dev as You participant Apps as Kustomer apps API participant Org as Installing org Dev->>Apps: POST /v1/apps/available (app definition) Apps->>Apps: Validate every component (dry run) Apps-->>Dev: 201, version registered Org->>Apps: Install app version Apps->>Apps: Dry run in the org, then create components Org->>Org: Admin fills in settings, connects OAuth Dev->>Apps: POST a new version (bumped semver) Org->>Apps: Update to the new version Apps->>Apps: Create new, update changed, remove dropped components ``` 1. **Register.** You `POST` the definition. Kustomer validates every component against the services that will own it, so a broken workflow or KView fails registration with nothing half-created. 2. **Install.** An admin installs from **Settings › Apps**, or you call the install endpoint. Kustomer validates again in that org (for example, a name clash with a Klass the org already has) and then creates each component. 3. **Configure.** Admins fill in settings, connect any OAuth account, and review the workflows the app installed. 4. **Update.** Each change ships as a new semver version. Installed orgs keep running their version until they update. ## Your first app in five minutes #### Get an API key Create an API key with the `org.admin.apps` role in a sandbox org. Registering an app can't be undone from the UI, so don't experiment in production. #### Write a definition ```json title="acme_orders.json" { "app": "acme_orders", "version": "0.1.0", "title": "Acme Orders", "description": "Orders from Acme, on the customer timeline.", "visibility": "private", "klasses": [ { "name": "acme_orders-order", "icon": "cart", "color": "#5B6CF9", "metadata": { "properties": { "orderNumberStr": { "displayName": "Order number" }, "totalNum": { "displayName": "Total" }, "placedAt": { "displayName": "Placed" } } } } ], "changelog": { "0.1.0": "{{October 2026}} First version." } } ``` #### Register it ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/apps/available" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ --data @acme_orders.json ``` ```bash title="kusty" kusty api POST /v1/apps/available --data @acme_orders.json ``` The `201` response contains the namespaced name, such as `acme_orders_5f8481c6a22ab5b9d5f2dd8a`, and the version ID `acme_orders_5f8481c6a22ab5b9d5f2dd8a-0.1.0`. #### Install it Open **Settings › Apps › App Directory**, find **Acme Orders**, and select **Install**. Or call the API: ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/apps/install" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"app": "acme_orders_5f8481c6a22ab5b9d5f2dd8a", "version": "0.1.0"}' ``` #### Use it Create an order with [`POST /v1/customers/{id}/klasses/acme_orders-order`](https://developer.kustomer.com/api/core-resources/create-customer-kobject-custom-object). Klass names aren't namespaced, so you use the name from your definition. The order appears on the customer's timeline with the cart icon. > **Start from the starter repo:** [`kustomer/kustomer-apps-starter-configs`](https://github.com/kustomer/kustomer-apps-starter-configs) contains example definitions and an `npm run register-new-version` script that reads your org's API base URL and token from `.env`. It's the fastest way to get a register-edit-register loop going. ## Where to go next - [App definition anatomy](https://developer.kustomer.com/docs/apps/app-definition): Every top-level key, its type and its rules. - [Klasses](https://developer.kustomer.com/docs/apps/klasses): Custom objects and attributes on standard objects. - [KViews](https://developer.kustomer.com/docs/apps/kviews): JSX in the timeline and insights panel, plus the Cards SDK. - [Workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers): Hooks in, actions out, workflows in between. - [Register and install](https://developer.kustomer.com/docs/apps/register-and-install): Endpoints, namespacing, errors and troubleshooting. - [Publish to the marketplace](https://developer.kustomer.com/docs/apps/marketplace): Review requirements and the submission checklist. > **For AI agents:** An app definition is a single JSON document validated against a strict schema: unknown top-level keys are rejected. Generate it, register it with `kusty api POST /v1/apps/available --data @app.json`, and read the error's `title` and `source` to fix validation failures. Always bump `version` before re-registering. --- # App definition anatomy > Every top-level key of a Kustomer app definition, with types, validation rules, naming conventions and a complete example. Source: https://developer.kustomer.com/docs/apps/app-definition An app definition is one JSON object. Three keys are required (`app`, `version` and `title`); everything else is optional and adds a kind of component. The schema is strict: a top-level key Kustomer doesn't recognize fails registration rather than being ignored. _An interactive map from each top-level app definition key to the part of Kustomer it affects, such as the App Directory, the settings page, the timeline, the insights panel, a widget, or behind-the-scenes workflows, hooks, webhooks and commands._ ## A complete example This app syncs orders from a fictional store, Acme. It stores orders as a custom object, shows recent orders in the insights panel, receives order updates on an inbound hook, and lets workflows issue refunds through Acme's API with each org's own key. ```json title="acme_orders.json" { "app": "acme_orders", "version": "1.4.0", "title": "Acme Orders", "identifier": "acmeord", "visibility": "private", "description": "See Acme orders on every customer and refund them from a workflow.\n\nSends the order ID and refund amount to Acme when a workflow issues a refund.", "iconUrl": "https://acme.example/kustomer/icon.png", "screenshots": ["https://acme.example/kustomer/screenshot-1.jpg"], "appDetails": { "appDeveloper": { "name": "Acme", "website": "https://acme.example", "supportEmail": "support@acme.example" }, "externalPlatform": { "name": "Acme Commerce", "website": "https://acme.example/commerce" }, "documentationLinks": [{ "title": "Set up Acme for Kustomer", "url": "https://acme.example/docs/kustomer" }] }, "tags": ["ecommerce"], "dependencies": ["kustomer-^1.9.0"], "settings": { "default": { "apiKey": { "type": "secret", "defaultValue": "", "required": true }, "storeDomain": { "type": "string", "defaultValue": "", "required": true } } }, "klasses": [ { "name": "acme_orders-order", "icon": "cart", "color": "#5B6CF9", "metadata": { "properties": { "orderNumberStr": { "displayName": "Order number" }, "statusStr": { "displayName": "Status" }, "totalNum": { "displayName": "Total" }, "trackingUrl": { "displayName": "Tracking" }, "placedAt": { "displayName": "Placed" } } } } ], "kviews": [ { "name": "kustomer.app.acme_orders.order-card", "resource": "kobject.acme_orders-order", "context": "smartbar-card", "meta": { "displayName": "Acme order", "icon": "cart" }, "template": "" } ], "hooks": [ { "eventName": "kustomer.app.acme_orders.order-updated", "displayEventName": "Acme order updated", "description": "Acme posts here whenever an order changes.", "type": "web" } ], "triggers": [ { "eventName": "kustomer.app.acme_orders.order-updated", "description": "An order changed in Acme", "outputTemplate": { "orderId": "/#data.attributes.data.id", "email": "/#data.attributes.data.customer.email", "status": "/#data.attributes.data.status" }, "outputSchema": { "type": "object", "properties": { "orderId": { "type": "string" }, "email": { "type": "string" }, "status": { "type": "string" } }, "additionalProperties": false }, "sampleOutputContext": { "orderId": "4417", "email": "ada@example.com", "status": "shipped" } } ], "actions": [ { "name": "kustomer.app.acme_orders.refund", "description": "Refund an Acme order", "type": "rest_api", "appSettings": { "apiKey": { "key": "acme_orders.default.apiKey" } }, "inputTemplate": { "uri": "https://api.acme.example/orders/{{orderId}}/refunds", "method": "POST", "headers": { "Authorization": "Bearer {{apiKey}}", "Content-Type": "application/json" }, "data": { "amount": "/#amount" }, "json": true }, "inputSchema": { "type": "object", "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" } }, "required": ["orderId"], "additionalProperties": false }, "outputTemplate": { "response": "/#response", "body": "/#body" }, "outputSchema": { "type": "object", "properties": { "response": { "type": "object" }, "body": { "type": "object" } }, "additionalProperties": false }, "sampleOutputContext": { "body": { "id": "rf_19", "status": "pending" } } } ], "i18n": { "en_us": { "acme_orders.settings.page.title": "Acme Orders", "acme_orders.settings.page.description": "Connect your Acme store.", "acme_orders.settings.path.default.apiKey.displayName": "API key", "acme_orders.settings.path.default.apiKey.description": "Create one in Acme under **Settings › API**.", "acme_orders.settings.path.default.storeDomain.displayName": "Store domain", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.displayName": "Refund order", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.description": "Issue a full or partial refund in Acme.", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.properties.orderId.displayName": "Order ID", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.properties.amount.displayName": "Amount" } }, "changelog": { "1.4.0": "{{October 2026}} Added the Refund order action.", "1.3.0": "{{August 2026}} Orders show their tracking link." } } ``` ## Identity and listing **Required** **Listing** **Platform** ## Components Each of these keys installs something in the org. Component names follow the [naming scheme](https://developer.kustomer.com/docs/apps/register-and-install#names-are-namespaced) `kustomer.app..`, and Kustomer applies it for you if you don't. | Key | Installs | Guide | | -------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `klasses` | Custom object types, and attributes on customers, companies, conversations, messages and users | [Klasses](https://developer.kustomer.com/docs/apps/klasses) | | `kviews` | JSX views in the timeline and insights panel | [KViews](https://developer.kustomer.com/docs/apps/kviews) | | `widgets` | Iframes opened from the navigation rail | [Widgets](https://developer.kustomer.com/docs/apps/widgets) | | `commands` | Named server-side requests your UI can run | [KViews › Commands](https://developer.kustomer.com/docs/apps/kviews#call-apis-with-commands) | | `hooks` | Inbound webhook URLs that emit events | [Workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#hooks-get-data-in) | | `triggers` | Named events with an output shape, for the workflow builder | [Triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#triggers-name-the-event) | | `actions` | Workflow steps that call your API | [Actions](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#actions-call-your-api) | | `workflows` | Complete automations | [Workflows](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#workflows-wire-it-together) | | `outboundWebhooks` | Event subscriptions that post to your service | [Outbound webhooks](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#outbound-webhooks) | | `settings` | Per-org configuration and an auto-generated settings page | [Settings](https://developer.kustomer.com/docs/apps/settings) | | `settingsPageConfig` | Your own settings page, in an iframe | [Settings](https://developer.kustomer.com/docs/apps/settings#build-your-own-settings-page) | | `auth` | An OAuth 2.0 connection to a third-party API | [OAuth for apps](https://developer.kustomer.com/docs/apps/oauth#call-third-party-apis-with-oauth) | | `i18n` | Display strings for settings, widgets, actions and triggers | [Internationalization](https://developer.kustomer.com/docs/apps/i18n) | | `shortcuts` | Saved replies that can also update the conversation | [Shortcuts](#shortcuts) | | `templates` | Starting points for reporting charts (`charts`) and business rules (`bizRules`) | [Templates](#templates) | `cards` is deprecated; use a KView with `DynamicCard` instead. ## Shortcuts A shortcut is a saved reply. Beyond inserting text, it can change the conversation when an agent uses it: ```json title="shortcuts (excerpt)" { "shortcuts": [ { "name": "acme-refund-issued", "draft": { "text": "Your refund is on its way. It can take 5 to 10 business days to appear." }, "conversation": { "tags": { "operator": "append", "value": ["refund"] }, "status": { "operator": "replace", "value": "done" } } } ] } ``` `conversation` can set `tags`, `assignedToUsers` and `assignedToTeams` (`append`, `replace` or `remove`), `name`, `status` and `subStatus` (`replace` or `remove`), `snooze` and `queue` (`replace`), and `custom` attributes (`replace`). Limit who sees the shortcut with `accessTeams` and `accessUsers`. ## Templates `templates.charts` and `templates.bizRules` add starting points to the reporting and business-rules builders, which admins use to build their own charts and rules. Business-rule templates hold the same `criteria` (with `and` and `or` groups of `_` conditions) and `actions` as a rule an admin builds. See [Criteria](https://developer.kustomer.com/docs/workflows/expressions-reference#criteria) for the condition format. ## Validation rules Registration checks the whole definition before creating anything. | Rule | Error | | ----------------------------------------- | ------------------------------------------------------------------------------- | | `app`, `version` and `title` are present | `400` | | No unknown top-level keys | `400` | | `vendor` and `website` aren't used | `400` "using deprecated fields! use appDetails…" | | `app` isn't registered by another org | `409` "The app name is already taken. Please choose a different name." | | `version` isn't already registered | `409` "This version has already been registered. Please increment the version." | | `version` is greater than the latest | `400` "Cannot register outdated version. Version must be greater than …" | | Every component passes its own validation | `400` from the service that owns the component, such as a workflow schema error | See [Register and install](https://developer.kustomer.com/docs/apps/register-and-install#troubleshooting) for fixes. ## Organize a large definition Real apps have dozens of workflows and long JSX templates, which are painful to edit inside one JSON string. Keep each component in its own file and assemble the definition in a build step: ```text title="Project layout" acme-orders/ ├── app.json # app, title, description, appDetails… ├── changelog.json ├── klasses/order.json ├── kviews/order-card.jsx ├── actions/refund.json ├── workflows/sync.json ├── i18n/en_us.json └── build.mjs ``` ```js title="build.mjs" import { readFileSync, readdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' const json = (p) => JSON.parse(readFileSync(p, 'utf8')) const all = (dir) => readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => json(join(dir, f))) const pkg = json('package.json') const definition = { ...json('app.json'), version: pkg.version, changelog: json('changelog.json'), klasses: all('klasses'), kviews: [ { name: 'kustomer.app.acme_orders.order-card', resource: 'kobject.acme_orders-order', context: 'smartbar-card', meta: { displayName: 'Acme order', icon: 'cart' }, template: readFileSync('kviews/order-card.jsx', 'utf8'), }, ], actions: all('actions'), workflows: all('workflows'), i18n: { en_us: json('i18n/en_us.json') }, } writeFileSync('dist/acme_orders.json', JSON.stringify(definition, null, 2)) ``` Taking `version` from `package.json` means `npm version minor` bumps the app and the build together. --- # Klasses > Define custom object types and add namespaced attributes to customers, companies, conversations, messages and users from an app definition. Source: https://developer.kustomer.com/docs/apps/klasses The `klasses` array does two jobs. An entry with a new name creates a **custom Klass**: a type of custom object, such as an order, whose records (KObjects) appear on the customer timeline. An entry named after a standard object, with `metadata.standard: true`, adds **custom attributes** to customers, companies, conversations, messages or users instead. For how KObjects behave once they exist (creating them, linking them to customers and companies, searching them) see [Custom objects](https://developer.kustomer.com/docs/concepts/custom-objects). ## Choose a Klass or an attribute | You need to store | Use | | ------------------------------------------------------------------------------ | ------------------------------ | | One value per customer, such as a loyalty tier | An attribute on `customer` | | One value per conversation, such as an order number the customer asked about | An attribute on `conversation` | | Many records per customer, such as orders, shipments or bookings | A custom Klass | | A record with its own lifecycle and several fields that agents review together | A custom Klass | | Data a KView or workflow needs to look up by its own `externalId` | A custom Klass | ## Define a custom Klass ```json title="klasses (excerpt)" { "klasses": [ { "name": "acme_orders-order", "icon": "cart", "color": "#5B6CF9", "metadata": { "properties": { "orderNumberStr": { "displayName": "Order number" }, "statusStr": { "displayName": "Status" }, "totalNum": { "displayName": "Total" }, "placedAt": { "displayName": "Placed" }, "giftMessageTxt": { "displayName": "Gift message" }, "isGiftBool": { "displayName": "Gift" }, "trackingUrl": { "displayName": "Tracking" } } } } ] } ``` **Klass** After install, admins can change the icon, color and attribute display names, and turn attributes on and off, in **Settings › Platform › Klasses**. They can't rename the Klass or its attributes. ### Metadata properties Each key is an attribute's system name in camelCase. Its suffix sets the type, exactly as for any [custom attribute](https://developer.kustomer.com/docs/concepts/custom-attributes#suffixes): | Suffix | Type | Example | | ------ | ------------- | ---------------- | | `Str` | Short string | `statusStr` | | `Txt` | Long text | `giftMessageTxt` | | `Num` | Number | `totalNum` | | `At` | Date and time | `placedAt` | | `Bool` | Boolean | `isGiftBool` | | `Url` | URL | `trackingUrl` | Each value is `{ "displayName": "…" }`, the label agents see. The system name is what the API, KViews, workflows and search use, so choose it carefully: you can't rename it later without migrating data. Set more attribute metadata, such as allowed values or a reference target for `Id` attributes, with [`PUT /v1/metadata/kobject.`](https://developer.kustomer.com/api/core-resources/update-custom-attribute-metadata) after install. ## Write and read records KObjects belong to a customer or company. Use the Klass name from your definition (it isn't namespaced) in the path: `POST /v1/customers/{id}/klasses/{name}` ([reference](https://developer.kustomer.com/api/core-resources/create-customer-kobject-custom-object)) ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/customers/$CUSTOMER_ID/klasses/acme_orders-order" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalId": "4417", "title": "Order #4417", "description": "2 items · shipped", "custom": { "orderNumberStr": "4417", "statusStr": "shipped", "totalNum": 86.5, "placedAt": "2026-10-19T14:02:00Z", "trackingUrl": "https://track.example/1Z999" } }' ``` ```bash title="kusty" kusty api POST /v1/customers/$CUSTOMER_ID/klasses/acme_orders-order --data @order.json ``` Set `externalId` to your own ID so you can find and update the record later with [`GET /v1/klasses/{name}/externalId={externalId}`](https://developer.kustomer.com/api/core-resources/get-kobject-by-external-id). Most apps write records from a workflow instead, with the `kustomer.kobject.create-with-customer` and `kustomer.kobject.update` actions; see [Workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#workflows-wire-it-together). ## Add attributes to standard objects Name the entry after a standard object and set `metadata.standard` to `true`: ```json title="klasses (excerpt)" { "identifier": "acmeord", "klasses": [ { "name": "customer", "metadata": { "standard": true, "properties": { "loyaltyTierStr": { "displayName": "Acme loyalty tier" }, "lifetimeValueNum": { "displayName": "Acme lifetime value" } } } }, { "name": "conversation", "metadata": { "standard": true, "properties": { "orderNumberStr": { "displayName": "Acme order number" } } } } ] } ``` `name` must be `company`, `conversation`, `customer`, `message` or `user`. > **Forgetting standard: true creates a custom Klass:** Without `"standard": true`, an entry named `customer` defines a new custom Klass called `customer` rather than adding attributes to customers. ### Attributes are namespaced Several apps can each add a `sectorStr` attribute to companies. To keep them apart, Kustomer renames every attribute an app adds to a standard object to `@` + the app's `identifier` + the name with its first letter capitalized: | In your definition | With `"identifier": "acmeord"` | | ---------------------------------- | ------------------------------ | | `loyaltyTierStr` | `@acmeordLoyaltyTierStr` | | `lifetimeValueNum` | `@acmeordLifetimeValueNum` | | `orderNumberStr` on `conversation` | `@acmeordOrderNumberStr` | Use the namespaced name everywhere you read or write the value: the API, KViews, workflows and search. ```json title="PATCH /v1/customers/{id}" { "custom": { "@acmeordLoyaltyTierStr": "gold", "@acmeordLifetimeValueNum": 1240 } } ``` ```json title="Workflow step params" { "action": "kustomer.conversation.update", "params": { "id": "/#steps.1.id", "custom": { "@acmeordOrderNumberStr": "/#steps.lookup.body.orderNumber" } } } ``` Set `identifier` yourself: 2 to 8 lowercase letters, digits or underscores, ideally an abbreviation of the app name. If you leave it out, Kustomer generates one at first registration. Either way it's permanent, so every version keeps the same attribute names. Only `displayName` is kept for attributes on standard objects; other keys in each property are dropped. ## Changes between versions | Change | Effect when an org updates | | ---------------------------------------- | ----------------------------------------------------------------------------------- | | Add an attribute | Added to the Klass | | Stop declaring an attribute | Values already written stay on existing records | | Remove a whole Klass from the definition | The Klass and its records stay. Kustomer never deletes Klasses when an app updates. | | Rename a Klass | A new, empty Klass is created next to the old one | Plan names before your first release, because you can't take them back. To retire an attribute, stop writing to it and tell admins they can deactivate it. ## Troubleshooting | Symptom | Check | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | An attribute isn't on the record | Is its suffix valid? Is it active in **Settings › Platform › Klasses**? Did you write the namespaced name for a standard object? | | A second Klass named after a standard object appeared | `metadata.standard: true` is missing. | | `400` writing a KObject | The Klass name in the path doesn't match the definition, or a value doesn't match its suffix's type. | | Search or a workflow condition finds nothing | Use the system name, not the display name, such as `kobject_custom_acme_orders-order_statusStr`. For standard objects, include the `@identifier` prefix. | --- # KViews > Render JSX inside the Kustomer timeline and insights panel, embed hosted UI with DynamicCard and the Cards SDK, and call external APIs safely with commands. Source: https://developer.kustomer.com/docs/apps/kviews A **KView** (Klass View) is a JSX template that Kustomer renders inside the agent desktop. Kustomer hosts and evaluates the template, so a KView needs no server: it reads the record being viewed, formats it with Kustomer's own components, and can call the Kustomer API or your app's commands when the agent clicks something. When you need more than a template can do, a KView can embed a page you host and talk to it through the Cards SDK. ## Define a KView ```json title="kviews (excerpt)" { "kviews": [ { "name": "kustomer.app.acme_orders.recent-orders", "resource": "customer", "context": "smartbar-card", "meta": { "displayName": "Acme orders", "icon": "cart", "state": "open" }, "template": "\n \n \n" } ] } ``` **KView** ## Where KViews appear | `context` | Renders | Allowed `resource` | | ------------------- | -------------------------------------------------------------- | ----------------------------- | | `smartbar-card` | A card in the insights panel on the right of the timeline | Any | | `smartbar-details` | The details panel at the top of the insights panel | Only a Klass your app defines | | `expanded-timeline` | The body of a KObject when an agent expands it in the timeline | Only a Klass your app defines | > **Don't take over shared views:** `smartbar-details` and `expanded-timeline` replace the org's own layout for that resource. Use them only for your app's Klasses; on `customer` or `conversation` they would conflict with the org's settings. _Figure: The Kustomer agent desktop with a customer timeline in the center. On the right, the insights panel shows the details panel at the top and an 'Acme orders' card below it. In the timeline, an expanded Acme order shows line items rendered by an expanded-timeline KView. Where each KView context renders._ ## Write the template A template is JSX evaluated in the browser. It can start with JavaScript (constants, helper functions, even React components) and must end with a single JSX expression: ```jsx title="kviews/order-timeline.jsx" const money = (n) => (n == null ? '–' : `$${Number(n).toFixed(2)}`) const statusColor = { shipped: 'var(--text-primary)', delayed: 'var(--alert-text)', refunded: 'var(--text-secondary)', } ;
{_.capitalize(object.custom.statusStr)}
{object.custom.trackingUrl && ( )}
``` Remember JSX isn't HTML: use `className`, `style={{ … }}` objects, and close every tag. To try a template before you save it, use the [KView playground](https://developer.kustomer.com/docs/apps/kview-components#try-it). ### What's in scope | Name | Contains | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `object` | The record the view renders. For a KObject: `id`, `custom`, `data`, `title`, `description`, `externalId` and so on. | | `customer` | The customer, flattened: `id` plus its attributes (`customer.name`, `customer.emails`, `customer.custom`). Relationships such as `company` become plain IDs. | | `conversation` | The conversation's attributes, when one is open | | `company` | The company's attributes, when there is one | | `currentUser` | The signed-in agent: `id`, their attributes, and `teams` | | `appSettings` | Your app's settings for this org. Secret settings aren't readable here. | | `context` | The context object passed to embedded cards | | `type` | The record type, such as `customer` or `kobject.order` | | `data` | All of the above as one object, for helpers such as `renderHandlebars` | | `_` | [lodash](https://lodash.com/docs) | | `moment` | [Moment.js](https://momentjs.com/docs/) for dates | | `React` | React, for components with state | Attributes can be missing, so read them with `_.get(customer, 'custom.loyaltyTierStr', 'none')` rather than chaining property access. ### Components Kustomer provides components that match its UI and respect dark mode and data masking. [KView components](https://developer.kustomer.com/docs/apps/kview-components) previews each one and lists every prop. | Group | Components | Use | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | Layout | `Container` (`` adds standard padding), `Segment`, `Grid`, `Column` (`size="one"` to `"sixteen"`, adding up to sixteen) | Structure and padding | | Timeline fields | `BasicField` (label and value), `Field` (label from metadata, typed formatting), `Images` | `expanded-timeline` views and cards | | Detail rows | `BasicRow`, `Row` (metadata-aware, inline editable), `RowAssignmentUser`, `RowAssignmentTeam`, `RowPriority`, `CreateFormRow` | `smartbar-details`, cards and create forms | | Relationship cards | `Customer`, `Company`, `Conversation`, `Kobject`, and `CustomerField`, `CompanyField`, `ConversationField`, `KobjectField` with a label | Link to related records | | Buttons and pills | `Button`, `ButtonSecondary`, `ButtonDanger`, `ButtonText`, `ButtonGroup` (`size`: `xSmall` or `small`; `onClick` or `link`), `Pill` (`type` and `message`) | Actions and status | | Data | `WithUser` (render prop with a user by ID) | Show an assignee's name | | Embedding | `Card`, `DynamicCard` | Your hosted UI, see below | `CollapsedRows`, from older generated layouts, is deprecated: it renders its children and no longer collapses them. `Row` writes back to the record when the agent edits it inline: ```jsx ``` ### Call the Kustomer API `KustomerRequest` calls the API as the signed-in agent, with their permissions. Return a string from the optional callback to show it as a toast: ```jsx ``` ### Handle masked data If the org uses data masking and the agent can't see a field, the template receives `{ isMasked: true, maskedPresentation: "••••@example.com" }` instead of the value. Kustomer's field components handle this for you. In your own markup, check before using the value: ```jsx const email = _.get(customer, 'emails[0].email') ;
{email?.isMasked ? ( ) : ( )}
``` ### Support dark mode Agents switch between light and dark themes, and Kustomer's components follow along. For your own markup, use Kustomer's theme variables instead of fixed colors, sizes and fonts: ```jsx
Acme loyalty
Gold member since 2024
``` Templates get the variables from the agent desktop. Pages in a `Card` or `DynamicCard` get them from the Cards SDK, which sets each one, and `color-scheme`, on the page's root element, and updates them when the agent switches theme (listen with `Kustomer.on('theme', …)` to re-render). The [playground](https://developer.kustomer.com/docs/apps/kview-components#try-it) previews both themes. | Purpose | Variables | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Backgrounds | `--background-primary`, `--background-secondary` and `--background-tertiary`, each with a `-hover` variant; `--background-primary-active`, `--background-primary-inverse` | | Text | `--text-primary`, `--text-secondary`, `--text-secondary-hover`, `--text-hint`, `--text-link`, `--text-link-hover`, `--text-info`, `--text-primary-inverse` | | Borders and forms | `--border-primary`; `--form-border` with `-hover`, `-active` and `-disabled`; `--form-background-disabled`, `--form-text-disabled`, `--form-text-placeholder` | | Icons | `--icon-primary`, `--icon-primary-disabled`, `--icon-active` | | Status | `--info-text`, `--info-background` and `--info-border`, and the same for `success`, `alert` and `danger`; `--info-secondary-text` and `--info-secondary-background`; `--neutral-text` and `--neutral-background`, with `--neutral-secondary-` variants | | Buttons | `--button-primary-background` and `--button-primary-text`; `--button-secondary-text` and `--button-secondary-border`; `--button-danger-text` and `--button-danger-border`; `--button-subtle-text`; each with `-hover` and `-disabled` variants | | Overlays | `--tooltip-text`, `--tooltip-background`, `--modal-overlay-background` | | AI | `--background-ai`, `--background-ai-subtle`, `--border-ai`, `--text-ai`, `--icon-ai`. These are gradients: use them as a `background`. | | Corner radius | `--border-radius-sharp` (4px), `--border-radius-primary` (8px), `--border-radius-large` (12px) | | Typography | `--font-family` (Inter), `--font-size` (14px), and `--font-weight-light`, `-normal`, `-medium`, `-semiBold` and `-bold` (300 to 700) | | Control heights | `--komponent-size-x-small` (26px), `--komponent-size-small` (30px), `--komponent-size-medium` (36px) | #### Older variable names Templates written before these variables existed use older names. Most are still set, so those templates keep working, but they're deprecated and don't always match the theme: `--font-secondary-color`, for example, is the same gray in light and dark mode. Replace them as you update a template. | Older name | Use instead | Status | | ---------------------------------------------------------- | -------------------------------------------------------------------- | ------------- | | `--background-primary-color` | `--background-primary` | Deprecated | | `--background-primary-color-hover` | `--background-primary-hover` | Deprecated | | `--background-primary-color-selected` | `--background-primary-active` | Deprecated | | `--background-primary-color-rgb` | `--background-primary`, with `color-mix()` for transparency | No longer set | | `--background-primary-color-inverse-rgb` | `--background-primary-inverse` | No longer set | | `--background-secondary-color` | `--background-secondary` | Deprecated | | `--background-tertiary-color` | `--background-tertiary` | Deprecated | | `--background-tertiary-color-hover` | `--background-tertiary-hover` | Deprecated | | `--background-tertiary-color-rgb` | `--background-tertiary`, with `color-mix()` | No longer set | | `--background-tertiary-color-hover-rgb` | `--background-tertiary-hover`, with `color-mix()` | No longer set | | `--background-nav-primary-color` | None. It's the color of Kustomer's navigation. | Deprecated | | `--background-modal-overlay-color-rgb` | `--modal-overlay-background` | No longer set | | `--background-search-results-has-active-users-color` | None | No longer set | | `--background-search-results-has-active-users-color-hover` | None | No longer set | | `--font-primary-color` | `--text-primary` | Deprecated | | `--font-primary-color-inverse` | `--text-primary-inverse` | Deprecated | | `--font-secondary-color` | `--text-secondary` | Deprecated | | `--font-secondary-hover-color` | `--text-secondary-hover` | Deprecated | | `--icon-primary-color` | `--icon-primary` | Deprecated | | `--icon-primary-color-disabled` | `--icon-primary-disabled` | Deprecated | | `--form-border-color` | `--form-border` | Deprecated | | `--form-border-color-hover` | `--form-border-hover` | Deprecated | | `--form-border-color-disabled` | `--form-border-disabled` | Deprecated | | `--color-hint` | `--background-secondary`. Despite the name, it's a background color. | Deprecated | ## Call APIs with commands A **command** is a named HTTP request defined in your app and executed by Kustomer's servers. Your template or iframe runs it by name. Kustomer fills in secret settings or the app's OAuth token, so credentials never reach the agent's browser. ```json title="commands (excerpt)" { "commands": [ { "name": "acme_orders.app.lookup-order", "displayName": "Look up an Acme order", "url": "https://{{{storeDomain}}}/api/orders/{{{orderId}}}", "httpMethod": "get", "permittedArgs": ["storeDomain", "orderId"], "appSettings": { "apiKey": { "key": "acme_orders.default.apiKey" } }, "defaultHeaders": { "Authorization": "Bearer {{{apiKey}}}" }, "cacheSeconds": 60, "outputSchema": { "type": "object", "properties": { "status": { "type": "string" }, "items": { "type": "array" } }, "additionalProperties": true } } ] } ``` **Command** The app definition is rejected if a command has any other field. > **Never pass secrets as args:** `args` come from the agent's browser, which can read and change them. Put API keys in a `secret` setting and reference it through `appSettings`. ### Run a command From a template, call `runCommand`. From an iframe, call `Kustomer.command.run`. Both go through the same endpoint, which you can also call directly: `POST /v1/commands/{name}/run` ```json title="Request body" { "args": { "storeDomain": "shop.acme.example", "orderId": "4417" } } ``` ```js title="Cards SDK" const order = await Kustomer.command.run('acme_orders.app.lookup-order', { args: { storeDomain: 'shop.acme.example', orderId: '4417' }, }) ``` The request body can also have `headers` and `body`. Both are templates too, so a caller can send `{ "Authorization": "Bearer {{{apiKey}}}" }` and the browser only ever sends the placeholder; `{{{apiKey}}}` is filled in on the server from the `appSettings` mapping. How templates are filled in: - **Use triple braces.** `{{apiKey}}` HTML-escapes the value, so `=` becomes `=` and `&` becomes `&`. `{{{apiKey}}}` inserts it as is. - **A string that is exactly `"/#name"`** inserts the value with its type, such as a number or boolean, instead of text. A falsy value (`0`, `false`, `""`) becomes `null`. - **Args win over settings.** An arg with the same name as a setting replaces it. - **`inputSchema` casts the body before templates are filled in.** A property typed `number` that holds `"/#amount"` becomes `NaN`, sent as `null`. Leave templated properties untyped. What your API receives: - A JSON request (`Content-Type: application/json`) with the rendered URL, headers and body. - `x-kustomer-org-id` and `x-kustomer-user-id` headers identifying the org and the agent who ran the command. Kustomer sets them after your headers, so a caller can't change them. - With `appAccountName`, the OAuth token in `Authorization: Bearer …`, or in the auth config's `customHeader`. Kustomer returns your response, filtered through `outputSchema`, as the command's result. A run fails with `400` before your API is called if it passes an arg that isn't in `permittedArgs` (`cannot run a command with unpermitted args provided in request body`), or if the body misses an `inputSchema` `required` property (`missing required params for inputSchema`) or has one it forbids (`unsupported property provided for inputSchema`). _A command builder. Edit a command definition as JSON and a run request with args, headers and body, and fill in the org's setting values, marking each as secret or not. The result shows either why the app definition is rejected (unknown fields such as headers instead of defaultHeaders, uppercase httpMethod, arg names shorter than 3 characters), the 400 error a run returns (unpermitted args, a missing required or forbidden inputSchema property), or the exact request your API receives: method, rendered URL, headers (Content-Type, defaultHeaders, request headers, x-kustomer-org-id, x-kustomer-user-id and an OAuth token) and the cast body, with each inserted value colored by where it came from and secrets marked. Examples cover an order lookup, Basic auth with base64Encode, a POST body cast by inputSchema and double braces HTML-escaping a query and an API key. Notes explain empty values, escaping, args replacing settings and the body replacing defaultBody._ ### Caching and cache fallback With `cacheSeconds` set, Kustomer stores every response for 24 hours: - A cached response younger than `cacheSeconds` is returned without calling your API. - If your API returns a `4xx` or `5xx`, **cache fallback mode** turns on for that combination of org, command, URL, headers and body. Kustomer returns the cached response even when it's stale, and retries your API on every fifth run. - The first successful (`< 400`) response turns fallback mode off. This keeps cards working through a rate limit or an outage on your side. ## Embed your own UI When a template isn't enough (you need your own frontend, a build step, or server-side code) render an iframe with `DynamicCard`. The page you host loads the Cards SDK to receive context and talk to Kustomer. ```jsx title="template" ``` | Component | Size | | ------------- | ----------------------------------------------------- | | `DynamicCard` | Resizes to fit as your page calls `Kustomer.resize()` | | `Card` | Fixed: set `height` (for example `"320px"`) | Pass a custom `context` object to send your own data instead of the default: ```jsx ``` ### The Cards SDK Load the SDK in the page you host: ```html ``` Kustomer loads your page with `parentUrl` and `id` query parameters, and the SDK uses them to exchange `postMessage` events only with the Kustomer window that opened it. Your page must be served over HTTPS and allow framing by Kustomer. ```js title="card.js" Kustomer.initialize(async (context, { userRoles }) => { render(context) Kustomer.resize() }) Kustomer.on('context', (context) => { // The agent moved to another conversation or timeline item. render(context) Kustomer.resize() }) async function refund(orderId) { try { await Kustomer.command.run('acme_orders.app.refund-order', { args: { orderId } }) Kustomer.handleTriggerToast({ type: 'success', message: `Refunded order ${orderId}` }) } catch ([err]) { Kustomer.updateStatus({ status: 'danger', description: 'Acme is unreachable. Refunds are paused.' }) } } ``` `context.type` is `customer`, `conversation`, `company`, `kobject.`, `widget`, `app` or your custom type. It carries the full JSON:API resources: `context.customer`, plus `context.conversation` or the KObject when one is selected. | Method | Does | | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `Kustomer.initialize(options?, callback)` | Handshake. Calls back once with `(context, { userRoles })`; also returns a promise. `options.debug: true` logs every message. | | `Kustomer.on(event, callback)` | Subscribe to `context`, `theme`, `flags`, `number-dialed`, `routing-status-change` or `kustomer-typing-status-changed`. | | `Kustomer.request({ url, method, body }, callback?)` | Call the Kustomer API as the agent. Rejects with `[error, data]`. | | `Kustomer.command.run(name, { args, headers, body })` | Run one of your app's commands. Resolves with the response's attributes. | | `Kustomer.resize({ height?, width? })` | Fit the iframe. With no height, measures your page. | | `Kustomer.updateStatus({ status, description })` / `Kustomer.clearStatus()` | Show or clear a banner on the card: `info`, `warning` or `danger`. | | `Kustomer.handleTriggerToast({ type, message })` | Show a toast: `info`, `warning`, `error` or `success`. | | `Kustomer.openCustomer(id)` / `Kustomer.openCustomerEvent(customerId, eventId)` | Navigate the agent. | | `Kustomer.expandInsightsCard({ appName, appLogo, title, height, url, context })` / `Kustomer.contractInsightsCard()` | Open a full-height view of the insights panel that stays open while the agent moves around the same timeline. | | `Kustomer.open()` / `Kustomer.close()` | Open a widget, or remove the iframe. | ### Modals A card can open up to three modals, each its own page that also loads the SDK: ```js await Kustomer.modal.init({ id: 'refund', url: 'https://kustomer.acme.example/refund', title: 'Refund order', additionalData: { orderId: '4417' } }) Kustomer.modal.show('refund') // Later, from the modal or the card: Kustomer.modal.hide('refund', 'Refund issued') Kustomer.modal.destroy('refund') ``` `init` registers a modal without showing it, and fails once three are registered; `destroy` or `destroyAll` frees the slots. `show` hides any other open modal. `hide` can show a success toast. `resize({ height, width })` resizes the visible modal. ## Troubleshooting | Symptom | Check | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The card is blank | A JavaScript error in the template. Check the browser console, and make sure the template ends with exactly one JSX expression. | | `Cannot read properties of undefined` | Read optional data with `_.get`, and guard on `conversation` in customer-level views. | | Text is invisible in dark mode | Replace fixed colors with the [theme variables](#support-dark-mode), and the older `--font-*-color` names with `--text-*`. | | The iframe never initializes | It must load the Cards SDK and be framed by Kustomer (with `parentUrl` and `id` in its URL). Turn on `Kustomer.initialize({ debug: true }, …)`. | | A command returns `400` | "cannot run a command with unpermitted args": an `args` key isn't in `permittedArgs` (or a `urlArgs` key isn't in `permittedUrlArgs`). Otherwise the body breaks `inputSchema`, or the command is disabled. | --- # KView components > Every component and helper Kustomer gives KView templates, with live previews, a map of where each one fits, a playground, and the props for each. Source: https://developer.kustomer.com/docs/apps/kview-components [KView](https://developer.kustomer.com/docs/apps/kviews) templates build their UI from components Kustomer provides. They match the agent desktop, follow the agent's light or dark theme, and handle masked data for you. This page shows where each one fits, lets you try them, and lists every prop. Components and helpers are global in a template: write `` or `_.get(…)` without importing anything. ## Where components go Each KView renders in one context, and some components are built for a specific one: `Row` edits attributes in the insights panel, `Field` lays out a KObject's body in the timeline, and `CreateFormRow` belongs in create forms. _A mock agent desktop with four outlined regions: the expanded KObject in the timeline (expanded-timeline), a create form (create-form), the details panel at the top of the insights panel (smartbar-details) and an app card in the insights panel (smartbar-card). Selecting a region lists the components built for it, such as Grid, Column, Field, Images and Pill for the timeline; Row, RowPriority, RowAssignmentUser and RowAssignmentTeam for details; BasicRow, Button, ButtonGroup and DynamicCard for cards; and CreateFormRow for create forms._ ## Browse the components Pick a component to see it render, change its props, and copy the template. _A filterable grid of every KView component and helper, each with a live preview: layout (Container, Segment, Grid, Column), timeline fields (Field, BasicField, Images), detail rows (Row, BasicRow, CreateFormRow, RowAssignmentUser, RowAssignmentTeam, RowPriority, the deprecated CollapsedRows), relationship cards (Customer, Company, Conversation, Kobject and their Field variants), buttons and pills, embedding (DynamicCard, Card), data (WithUser) and helpers (lodash, moment, KustomerRequest, runCommand, renderHandlebars, internationalizeStandardAttribute, renderSnippet). Selecting one shows its preview, prop controls, the template and its props table._ ## Try it Write a template and see it render in any context. The **Data** tab holds the variables your template reads, as in the Data tab of Kustomer's KView editor; edit them to test missing or unusual values. Turn on **Mask** to see what an agent who can't view the customer's email and phone gets, and switch the preview to dark mode to check your colors. _A KView editor beside a live preview. Choose a starter (an order in the timeline, a customer card, buttons that call APIs, conversation details, masked data, theme variables, a create form, or state with hooks) and a context. The Code tab holds the template and the Data tab the JSON it reads. The preview renders in the matching part of the agent desktop, in light or dark mode, with masking on or off; errors point to the line in the template. Buttons that call KustomerRequest or runCommand are logged in a Requests panel instead of being sent._ The playground follows Kustomer's rules: context variables are global, the template's last JSX expression is what renders, and syntax errors show the line and column. API calls, commands, links and edits are logged instead of sent, and relationship cards only find the records in the sample data. ## Show rows only when they apply In the insights panel and in create forms, a row can depend on other attributes: show **Return reason** only when the conversation's **Contact reason** is `return`, for example. Set this in the KView editor's **Conditions** tab, or in the visual builder with **Only show this attribute if…**. Conditions aren't available for `expanded-timeline` views. _A condition builder. Pick a view (conversation details, customer details, an order create form, or an order in the timeline) and the row to show or hide, then add up to three conditions joined by OR, each with an attribute, an operator (Equals, Has any value, and Starts with or Is within where the attribute allows it) and a value, plus an option to require the row before the conversation is marked done. Beside it, the Conditions JSON updates as you build, with a breakdown of each key into resource, custom marker, klass and attribute, and the 400 errors Kustomer returns for a missing value or a timeline view. Below, a preview of the insights panel lets you change the sample record and see each condition pass or fail, and the row show, hide, or become required._ The Conditions tab holds a JSON object with a key for each row that has conditions: ```json title="Conditions" { "conversation_custom_returnReasonStr": { "condition": { "op": "or", "values": [ { "op": "eq", "property": "conversation_custom_contactReasonStr", "values": ["return"], "dependentFieldRequired": true }, { "op": "exists", "property": "conversation_custom_trackingNumberStr", "values": [] } ] } } } ``` | Key | Meaning | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attribute keys | `_` for standard attributes, such as `conversation_brand`; `_custom_` for custom ones, such as `customer_custom_loyaltyTierStr`; and `kobject_custom__` for KObjects, such as `kobject_custom_order_giftBool`. | | `condition.op` | Always `or`: the row shows when any condition matches. | | `values[].op` | `eq` (equals the value; text ignores case), `exists` (has any value) or `startsWith`. The editor offers `startsWith` for free-text custom attributes and conversation and company names, where it's case-sensitive, and for tree attributes, where it matches the value or anything under it. | | `values[].property` | The attribute to test, in the same key format: one of the view's own custom attributes, or a standard conversation, customer or company attribute. | | `values[].values` | Required. One value, such as `["return"]` or `[true]`: Kustomer only checks the first, so add a condition for each value you want to match. Use `[]` with `exists`. | | `values[].dependentFieldRequired` | Requires the row before the conversation is marked done, once that condition matches. Only for custom conversation attributes in `smartbar-details` views, and not on conditions that test `conversation_status` or `conversation_subStatus`. | When a row's conditions stop matching, Kustomer hides it and clears its value if the agent can edit the record, so a stale answer doesn't stay behind. List attributes such as `conversation_channels` match `eq` when they include the value, and `exists` when they aren't empty. ## Masked data and dark mode `Field`, `BasicField`, `Row`, `BasicRow` and the other field components show masked values the way the agent is allowed to see them, and Kustomer turns a masked value placed directly inside an element, such as `
{email}
`, into its masked text. Anywhere else, such as inside a template string or a URL, you get the masked object itself. See [Handle masked data](https://developer.kustomer.com/docs/apps/kviews#handle-masked-data). Every component follows the agent's theme. For your own markup, use the [theme variables](https://developer.kustomer.com/docs/apps/kviews#support-dark-mode) instead of fixed colors. ## Layout ### `Container` An outer wrapper that can add Kustomer's standard padding. Works in every context. | Prop | Type | Default | Description | | ------------ | ------- | ------- | ----------------------------------------------------------- | | `usePadding` | boolean | | Adds 8px of padding on every side. Ignored in create forms. | | `children` | JSX | | The content. | ```jsx ``` ### `Segment` Groups related content and leaves 28px of space below it. Works in every context. | Prop | Type | Default | Description | | ---------- | ------ | ------- | -------------------------------------------------------------------------------------- | | `style` | object | | Inline styles as an object, such as `style={{ padding: 8 }}`. A CSS string is ignored. | | `children` | JSX | | The content. | ```jsx
``` ### `Grid` Lays out `Column`s in a row 16 units wide. Columns past 16 wrap to the next line; nothing checks the sum. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ---------- | ------ | ------- | -------------------------------------------------------------------------------------- | | `style` | object | | Inline styles as an object, such as `style={{ padding: 8 }}`. A CSS string is ignored. | | `children` | JSX | | One or more `Column`s. | ```jsx ``` ### `Column` One column of a `Grid`, sized in sixteenths of the row. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ---------- | ----------------- | --------- | -------------------------------------------------------------------------------------- | | `size` | `one` … `sixteen` | `sixteen` | The width in sixteenths, as a word. A number, such as `size={8}`, has no effect. | | `style` | object | | Inline styles as an object, such as `style={{ padding: 8 }}`. A CSS string is ignored. | | `children` | JSX | | The content. | ```jsx ``` ## Timeline fields ### `Field` A labeled value, formatted from the attribute's metadata, so dates, numbers, booleans and links look the way they do elsewhere in Kustomer. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------- | | `field` | string | | **Required.** The attribute name, such as `totalNum`. A name ending in `Rel` renders the related KObject as a card. | | `type` | string | | **Required.** The record type the attribute belongs to: `customer`, `company`, `conversation` or `kobject.`. | | `value` | any | | The value, usually read with `_.get(object, 'custom.totalNum')`. | | `label` | string | | Replaces the attribute's display name. | ```jsx ``` ### `BasicField` A label and a value as you pass them, in the field layout. It doesn't read metadata or format values. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ----------- | | `label` | string | | The label. | | `value` | any | | The value. | ```jsx ``` ### `Images` An image carousel with a dot for each image, up to 450px tall. Renders nothing for an empty array. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | -------- | --------- | ------- | -------------------------------------------------------------------------------------- | | `images` | string\[] | | **Required.** Image URLs. | | `style` | object | | Inline styles as an object, such as `style={{ padding: 8 }}`. A CSS string is ignored. | ```jsx ``` ## Detail rows ### `Row` An attribute row for the insights panel. Agents click the value to edit it, and Kustomer saves it to the record. Designed for `smartbar-card` and `smartbar-details`. | Prop | Type | Default | Description | | --------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------- | | `field` | string | | **Required.** The attribute name, such as `returnReasonStr`. | | `object` | string | | **Required.** `conversation`, `customer`, `company` or `kobject`: the record the row belongs to and saves to. | | `value` | any | | The current value, such as `_.get(conversation, 'custom.returnReasonStr')`. | | `label` | string | | Replaces the attribute's display name. | | `readOnly` | boolean | | Shows the value without letting agents edit it. | | `editable` | boolean | | Lets agents edit a KObject attribute. Conversation, customer and company rows are editable unless `readOnly`. | | `componentAttributes` | object | | `{ readOnly, fullWidth, displayName }`. `fullWidth: true` puts the value under the label. | | `placeholder` | string | `—` | Shown when there's no value. | | `context` | string | | `create-form` renders a `CreateFormRow` instead. | ```jsx ``` ### `BasicRow` A label and a value in the row layout. It isn't editable and doesn't read metadata. Designed for `smartbar-card` and `smartbar-details`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ----------- | | `label` | string | | The label. | | `value` | any | | The value. | ```jsx ``` ### `CreateFormRow` One field of a KObject create form, with the input for its type. Designed for `create-form`. | Prop | Type | Default | Description | | ------- | ------ | ------- | --------------------------------------------------------------- | | `field` | string | | **Required.** The attribute name. | | `klass` | string | | **Required.** The Klass being created, such as `kobject.order`. | ```jsx
``` ### `RowAssignmentUser` The conversation's assignee, with Kustomer's picker for changing it. Designed for `smartbar-details`. ```jsx ``` ### `RowAssignmentTeam` The conversation's team. Renders nothing when the org has no teams. Designed for `smartbar-details`. ```jsx ``` ### `RowPriority` The conversation's priority, 1 to 5. Agents click to change it. Designed for `smartbar-details`. ```jsx ``` ### `CollapsedRows` A wrapper that older generated layouts put around rows. It no longer collapses anything. Designed for `smartbar-details`. > **Deprecated:** Renders its children unchanged. Leave it out of new templates, or use `Segment` to group rows. | Prop | Type | Default | Description | | ---------- | ---- | ------- | ------------ | | `children` | JSX | | The content. | ```jsx ``` ## Relationship cards ### `Customer` A link to a customer: avatar and name. Renders nothing until the customer loads. Works in every context. | Prop | Type | Default | Description | | ---- | ------ | ------- | -------------------------------- | | `id` | string | | **Required.** The customer's ID. | ```jsx ``` ### `CustomerField` `Customer` with a label, in the field layout. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ---------------------------------------- | | `id` | string | | **Required.** The customer's ID. | | `label` | string | | **Required.** The label beside the card. | ```jsx ``` ### `Company` A link to a company. Renders nothing until the company loads. Works in every context. | Prop | Type | Default | Description | | ---- | ------ | ------- | ------------------------------- | | `id` | string | | **Required.** The company's ID. | ```jsx ``` ### `CompanyField` `Company` with a label, in the field layout. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ---------------------------------------- | | `id` | string | | **Required.** The company's ID. | | `label` | string | | **Required.** The label beside the card. | ```jsx ``` ### `Conversation` A link to a conversation, with its channel's icon. Works in every context. | Prop | Type | Default | Description | | ---- | ------ | ------- | ------------------------------------ | | `id` | string | | **Required.** The conversation's ID. | ```jsx ``` ### `ConversationField` `Conversation` with a label, in the field layout. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ---------------------------------------- | | `id` | string | | **Required.** The conversation's ID. | | `label` | string | | **Required.** The label beside the card. | ```jsx ``` ### `Kobject` A link to a KObject, with its title. Works in every context. | Prop | Type | Default | Description | | ------ | ------ | ------- | ------------------------------------------------- | | `id` | string | | **Required.** The KObject's ID. | | `type` | string | | **Required.** The Klass, such as `kobject.order`. | ```jsx ``` ### `KobjectField` `Kobject` with a label, in the field layout. Designed for `expanded-timeline` and `smartbar-card`. | Prop | Type | Default | Description | | ------- | ------ | ------- | ------------------------------------------------- | | `id` | string | | **Required.** The KObject's ID. | | `type` | string | | **Required.** The Klass, such as `kobject.order`. | | `label` | string | | **Required.** The label beside the card. | ```jsx ``` ## Buttons and pills ### `Button` The primary action in a view. Works in every context. | Prop | Type | Default | Description | | ------------ | ----------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- | | `children` | string | | **Required.** The label. | | `onClick` | function | | Runs when the agent clicks. Call `KustomerRequest` or `runCommand` here. | | `link` | string | | A URL to open in a new tab instead. The button renders as a link. | | `icon` | string | | A [Material Design Icons](https://pictogrammers.com/library/mdi/) name shown before the label, such as `refresh`. | | `size` | `xSmall` \| `small` | `small` | 26px or 30px tall. `medium` is deprecated and renders as `small`. | | `grow` | `auto` \| `fill` | `auto` | `fill` stretches the button to the width of its container. | | `marginSize` | `none` \| `small` \| `medium` \| `large` \| `xlarge` \| `xxlarge` | `medium` | Space above and below. `medium` is 8px. | | `theme` | `default` \| `ai` | `default` | `ai` uses the gradient Kustomer gives AI actions. | | `disabled` | boolean | | Grays the button out; clicks don't run. | | `loading` | boolean | | Shows a spinner in place of the icon and disables the button. | ```jsx ``` ### `ButtonSecondary` An outlined button for actions beside a primary one. Works in every context. | Prop | Type | Default | Description | | ------------ | ----------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- | | `children` | string | | **Required.** The label. | | `onClick` | function | | Runs when the agent clicks. Call `KustomerRequest` or `runCommand` here. | | `link` | string | | A URL to open in a new tab instead. The button renders as a link. | | `icon` | string | | A [Material Design Icons](https://pictogrammers.com/library/mdi/) name shown before the label, such as `refresh`. | | `size` | `xSmall` \| `small` | `small` | 26px or 30px tall. `medium` is deprecated and renders as `small`. | | `grow` | `auto` \| `fill` | `auto` | `fill` stretches the button to the width of its container. | | `marginSize` | `none` \| `small` \| `medium` \| `large` \| `xlarge` \| `xxlarge` | `medium` | Space above and below. `medium` is 8px. | | `theme` | `default` \| `ai` | `default` | `ai` uses the gradient Kustomer gives AI actions. | | `disabled` | boolean | | Grays the button out; clicks don't run. | | `loading` | boolean | | Shows a spinner in place of the icon and disables the button. | ```jsx KustomerRequest({ url: `/v1/customers/${customer.id}` }, (err) => (err ? 'Refresh failed' : 'Customer refreshed'))} > Refresh ``` ### `ButtonDanger` An outlined red button for destructive actions, such as cancelling an order. Works in every context. | Prop | Type | Default | Description | | ------------ | ----------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | | `children` | string | | **Required.** The label. | | `onClick` | function | | Runs when the agent clicks. Call `KustomerRequest` or `runCommand` here. | | `link` | string | | A URL to open in a new tab instead. The button renders as a link. | | `icon` | string | | A [Material Design Icons](https://pictogrammers.com/library/mdi/) name shown before the label, such as `refresh`. | | `size` | `xSmall` \| `small` | `small` | 26px or 30px tall. `medium` is deprecated and renders as `small`. | | `grow` | `auto` \| `fill` | `auto` | `fill` stretches the button to the width of its container. | | `marginSize` | `none` \| `small` \| `medium` \| `large` \| `xlarge` \| `xxlarge` | `medium` | Space above and below. `medium` is 8px. | | `disabled` | boolean | | Grays the button out; clicks don't run. | | `loading` | boolean | | Shows a spinner in place of the icon and disables the button. | ```jsx KustomerRequest({ url: `/v1/customers/${customer.id}` }, (err) => (err ? 'Refresh failed' : 'Customer refreshed'))} > Refresh ``` ### `ButtonText` A button without a border, for low-emphasis actions. Works in every context. | Prop | Type | Default | Description | | ------------ | ----------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- | | `children` | string | | **Required.** The label. | | `onClick` | function | | Runs when the agent clicks. Call `KustomerRequest` or `runCommand` here. | | `link` | string | | A URL to open in a new tab instead. The button renders as a link. | | `icon` | string | | A [Material Design Icons](https://pictogrammers.com/library/mdi/) name shown before the label, such as `refresh`. | | `endIcon` | string | | An icon name shown after the label. | | `size` | `xSmall` \| `small` | `small` | 26px or 30px tall. `medium` is deprecated and renders as `small`. | | `grow` | `auto` \| `fill` | `auto` | `fill` stretches the button to the width of its container. | | `marginSize` | `none` \| `small` \| `medium` \| `large` \| `xlarge` \| `xxlarge` | `medium` | Space above and below. `medium` is 8px. | | `theme` | `default` \| `danger` \| `subtle` \| `white` \| `ai` | `default` | The text color. `ai` uses the AI gradient. | | `disabled` | boolean | | Grays the button out; clicks don't run. | | `loading` | boolean | | Shows a spinner in place of the icon and disables the button. | ```jsx KustomerRequest({ url: `/v1/customers/${customer.id}` }, (err) => (err ? 'Refresh failed' : 'Customer refreshed'))} > Refresh ``` ### `ButtonGroup` A row of buttons with 8px between them. Works in every context. | Prop | Type | Default | Description | | ---------- | ---------------- | ------- | ------------------------------------------------- | | `grow` | `auto` \| `fill` | `auto` | `fill` shares the full width between the buttons. | | `children` | JSX | | The buttons. | ```jsx Open in Acme ``` ### `Pill` A small rounded status label. Each `type` has its own colors, and some add an icon. Works in every context. | Prop | Type | Default | Description | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `message` | string | | **Required.** The text. Pill takes `message`, not children. | | `type` | `info` \| `infoSecondary` \| `success` \| `alert` \| `danger` \| `active` \| `inactive` \| `complete` \| `activeComplete` \| `ai` \| `neutralLight` \| `neutralMedium` \| `warning` \| `error` | | **Required.** `warning` looks like `alert`, `error` like `danger`, `neutralLight` like `info` and `neutralMedium` like `infoSecondary`. | | `icon` | string | | Replaces the type's icon. | | `removeIcon` | boolean | | Hides the type's icon. | | `tooltip` | string | | Shown on hover. | | `tooltipPosition` | string | `top` | Where the tooltip opens: `top`, `bottom`, `left` or `right`. | ```jsx ``` ## Embedding ### `DynamicCard` An iframe of a page you host, sized to fit as the page calls `Kustomer.resize()` from the Cards SDK. Works in every context. | Prop | Type | Default | Description | | ------------- | ------- | ------- | --------------------------------------------------------------------------------------------------- | | `src` | string | | **Required.** Your page, over HTTPS. | | `context` | object | | Sent to your page when it initializes and whenever the agent moves. Defaults to the view's context. | | `fillToWidth` | boolean | | Stretches to the width of the panel. | | `noPadding` | boolean | | Removes the 10px of padding around the iframe. | ```jsx ``` ### `Card` An iframe of a page you host, at a fixed size. Works in every context. | Prop | Type | Default | Description | | ------------------- | ---------------- | ------- | -------------------------------------------------------------------------------------------- | | `src` | string | | **Required.** Your page, over HTTPS. | | `height` | number \| string | | The height, such as `320` or `"320px"`. | | `width` | number \| string | `300` | The width. | | `context` | object | | Sent to your page, as with `DynamicCard`. | | `fillToWidth` | boolean | | Stretches to the width of the panel. | | `noPadding` | boolean | | Removes the 10px of padding. | | `modalHeight` | number | `300` | The height of modals the page opens. | | `modalWidth` | number | `500` | The width of modals the page opens. | | `iframePermissions` | string | | The iframe's `allow` attribute. Defaults to `clipboard-read; clipboard-write; microphone *`. | ```jsx ``` ## Data ### `WithUser` Loads a Kustomer user and passes it to a function you write. Renders nothing until the user loads. Works in every context. | Prop | Type | Default | Description | | ---------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------- | | `id` | string | | **Required.** The user's ID, such as `conversation.assignedUsers[0]`. | | `children` | `(user) => JSX` | | **Required.** Called with the user, flattened: `id`, `name`, `displayName`, `email` and its other attributes. | ```jsx {(user) => } ``` ## Helpers ### `_` [Lodash](https://lodash.com/docs/4.17.15). `_.get` reads nested values without throwing when a parent is missing. Works in every context. ```jsx ``` ### `moment` [Moment.js](https://momentjs.com/docs/), for formatting and comparing dates. Works in every context. ```jsx ``` ### `KustomerRequest` Calls the Kustomer API as the agent, with the agent's permissions. Returns a promise that resolves to the response, or to the error: it never rejects. Works in every context. | Parameter | Type | Default | Description | | ----------------------- | ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- | | `request` | `{ url, method, body }` | | **Required.** A relative API path, such as `/v1/customers/{id}`; the HTTP method (default `GET`); and a body, sent as JSON. | | `formatResponseMessage` | `(err, attributes) => string` | | Called with `(undefined, response.data.attributes)` on success and `(error)` on failure. Return a string to show it as a toast. | ```jsx ``` ### `runCommand` Runs one of your app's commands, so Kustomer's servers call your API with the app's secrets. Works in every context. | Parameter | Type | Default | Description | | ----------------------- | ----------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | | **Required.** The command name, such as `acme_orders.app.lookup-order`. | | `body` | `{ args, headers, body }` | | The same body as the [run endpoint](https://developer.kustomer.com/docs/apps/kviews#run-a-command). | | `formatResponseMessage` | `(err, attributes) => string` | | Called with the run's attributes, including `statusCode`. A string becomes a toast: a success toast below 400, an error toast otherwise. | ```jsx ``` ### `renderHandlebars` Fills in merge fields, such as `{{customer.email}}`, from the view's data. Returns the fallback when any of them is empty. Works in every context. | Parameter | Type | Default | Description | | -------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------- | | `value` | string | | **Required.** Text with merge fields. Each record also has `email` and `phone` shortcuts for its first email and phone. | | `fallbackLink` | string | | **Required.** Returned when a merge field has no value. | | `data` | object | | **Required.** The values to merge, usually `data`, the template's whole context. | ```jsx Find in Acme ``` ### `internationalizeStandardAttribute` Returns a standard attribute's display name in the agent's language, or the attribute name if it isn't a standard attribute. Works in every context. | Parameter | Type | Default | Description | | ----------- | ------ | ------- | ----------------------------------------------------------------------- | | `attribute` | string | | **Required.** The attribute name, such as `createdAt`. | | `klass` | string | | **Required.** `customer`, `company`, `conversation` or a KObject Klass. | ```jsx ``` ### `renderSnippet` Renders one of the org's snippets in the agent's language, or the key if there's no such snippet. Works in every context. | Parameter | Type | Default | Description | | ------------ | ------ | ------- | ------------------------------ | | `snippetKey` | string | | **Required.** The snippet key. | ```jsx ``` --- # Widgets > Add an iframe agents can open from the Kustomer navigation rail on any page, with per-org URLs, sizing, browser permissions and background loading. Source: https://developer.kustomer.com/docs/apps/widgets A **widget** is a page you host that agents open from the navigation rail, whatever they're looking at. Use one for tools agents need alongside every conversation: a softphone, an order lookup, a knowledge tool, a portal into another system. Unlike a [KView](https://developer.kustomer.com/docs/apps/kviews), a widget isn't tied to a record. It stays open as the agent moves around, and receives context updates through the Cards SDK. If an org has one widget, its icon sits directly in the rail. With several, they're grouped under a widgets menu. ## Define a widget ```json title="widgets (excerpt)" { "settings": { "default": { "storeDomain": { "type": "string", "defaultValue": "", "required": true } } }, "widgets": [ { "name": "order-lookup", "title": "Acme lookup", "url": "https://kustomer.acme.example/widget?store={{{settings.default.storeDomain}}}", "icon": "https://kustomer.acme.example/widget-icon.png", "width": "24vw", "height": "60vh", "iframePermissions": "microphone *; autoplay *;", "backgroundLoad": false } ] } ``` **Widget** > **Sizing:** Agents use Kustomer on everything from laptops to ultra-wide monitors. Prefer viewport units (`vw`, `vh`) or `calc()` with a pixel minimum over fixed pixel sizes. ## Talk to Kustomer from the widget A widget page loads the same [Cards SDK](https://developer.kustomer.com/docs/apps/kviews#the-cards-sdk) as an embedded KView: ```html title="widget.html" ``` The widget context has `type: "widget"` and `widget.isOpen`, so your page can react when the agent opens or closes it. From there you can: - Read and write Kustomer data with `Kustomer.request` (as the agent). - Call your own API with credentials kept server-side through [commands](https://developer.kustomer.com/docs/apps/kviews#call-apis-with-commands). - Send the agent to a customer with `Kustomer.openCustomer(id)` or `Kustomer.openCustomerEvent(customerId, eventId)`. - Open the widget with `Kustomer.open()`, for example on an incoming call. ### Voice and presence events Softphone widgets can subscribe to telephony and routing events: | Event | Fires when | | ----------------------- | ------------------------------------------------------------- | | `number-dialed` | An agent clicks a phone number in Kustomer to dial it | | `routing-status-change` | The agent's routing status changes, such as available to busy | ```js Kustomer.on('number-dialed', (payload) => softphone.dial(payload)) ``` ## Background loading `backgroundLoad: true` loads your page for every agent in the org as soon as Kustomer opens. Keep that cheap: - Load under 1 MB of assets before the agent interacts. - Keep `console` output to a minimum. - Don't open the widget automatically unless something needs the agent, like an incoming call. Several auto-opening widgets competing for attention frustrate agents. ## Design checklist - [ ] Works at the smallest width you set, and at 2× that. - [ ] Uses the theme's [CSS variables](https://developer.kustomer.com/docs/apps/kviews#support-dark-mode) so it's readable in dark mode. - [ ] Handles [masked fields](https://developer.kustomer.com/docs/apps/kviews#handle-masked-data) in context data. - [ ] Shows a useful state when settings are missing, instead of a blank frame. - [ ] Served over HTTPS and allows framing by Kustomer (no `X-Frame-Options: DENY`, and a `frame-ancestors` policy that includes your customers' Kustomer domains). --- # Workflows, actions and triggers > Bring data into Kustomer with app hooks, name it with triggers, call your API with REST actions, ship ready-made workflows, and subscribe to events with outbound webhooks. Source: https://developer.kustomer.com/docs/apps/workflows-actions-triggers Most integrations follow one loop: your platform tells Kustomer something happened, a workflow decides what to do, and Kustomer calls your platform back. An app packages each piece so it's installed and configured the same way in every org. ```mermaid flowchart LR P[Your platform] -- POST --> H[hooks
per-install URL] H -- event --> T[triggers
name + output shape] T --> W[workflows] W --> K[Kustomer actions
create, update, find] W --> A[actions
your REST API] A -- HTTPS --> P E[(Kustomer events)] -- outboundWebhooks --> P ``` | Key | Does | Runs | | ------------------ | ------------------------------------------------- | ----------------------------------------- | | `hooks` | Creates an inbound webhook URL in each org | When your platform posts to it | | `triggers` | Describes a hook's event for the workflow builder | At the start of each workflow run | | `actions` | Packages a call to your API as a workflow step | Inside workflows, yours and the org's own | | `workflows` | Installs complete automations | On their trigger | | `outboundWebhooks` | Subscribes your URL to Kustomer events | On every matching event | This page covers what's specific to apps. For how workflows run, their JSON format, conditions and expressions, see the [Workflows](https://developer.kustomer.com/docs/workflows) section. ## Hooks: get data in ```json title="hooks (excerpt)" { "hooks": [ { "eventName": "kustomer.app.acme_orders.order-updated", "displayEventName": "Acme order updated", "description": "Acme posts the full order whenever it changes.", "type": "web" } ] } ``` **Hook** Each install gets its own hook URL, which admins find on the app's page in the App Directory. Your platform needs that URL, so either ask admins to paste it into your platform (say how in `postInstallMessage`), or have a [custom settings page](https://developer.kustomer.com/docs/apps/settings#build-your-own-settings-page) read it and register it with your platform for them. For request formats, size limits and debugging, see [Inbound webhooks](https://developer.kustomer.com/docs/events/inbound-webhooks). > **Form hooks are public:** Anyone with a form hook's URL can post to it. Use `web` hooks for server-to-server data, or verify a shared secret in your workflow's first condition. ## Triggers: name the event A trigger tells the workflow builder about a hook's event: what to call it, and what data a workflow receives. ```json title="triggers (excerpt)" { "triggers": [ { "eventName": "kustomer.app.acme_orders.order-updated", "description": "An order changed in Acme", "outputTemplate": { "orderId": "/#data.attributes.data.id", "email": "/#data.attributes.data.customer.email", "status": "/#data.attributes.data.status", "total": "/#data.attributes.data.total" }, "outputSchema": { "type": "object", "properties": { "orderId": { "type": "string" }, "email": { "type": "string" }, "status": { "type": "string" }, "total": { "type": "number" } }, "additionalProperties": false }, "sampleOutputContext": { "orderId": "4417", "email": "ada@example.com", "status": "shipped", "total": 86.5 } } ] } ``` **Trigger** With both `outputTemplate` and `outputSchema`, a workflow reads the shaped values as `/#steps.1.orderId`. Without them, step 1 is the raw hook record and the posted body is at `/#steps.1.attributes.data.`. The shaped form is easier for admins building their own workflows, and casting means `"86.50"` arrives as the number `86.5`. Name the trigger in the builder with [i18n](https://developer.kustomer.com/docs/apps/i18n#workflow-builder-strings) keys. ## Actions: call your API An action is a reusable workflow step with typed inputs. Third-party apps use the `rest_api` type, which makes an HTTP request: ```json title="actions (excerpt)" { "actions": [ { "name": "kustomer.app.acme_orders.refund", "description": "Refund an Acme order", "type": "rest_api", "appSettings": { "apiKey": { "key": "acme_orders.default.apiKey" }, "storeDomain": { "key": "acme_orders.default.storeDomain" } }, "inputTemplate": { "uri": "https://{{storeDomain}}/api/orders/{{orderId}}/refunds", "method": "POST", "headers": { "Authorization": "Bearer {{apiKey}}", "Content-Type": "application/json" }, "data": { "amount": "/#amount", "reason": "/#reason" }, "json": true }, "inputSchema": { "type": "object", "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" }, "reason": { "type": "string" } }, "required": ["orderId"], "additionalProperties": false }, "outputTemplate": { "response": "/#response", "body": "/#body" }, "outputSchema": { "type": "object", "properties": { "response": { "type": "object" }, "body": { "type": "object" } }, "additionalProperties": false }, "sampleOutputContext": { "response": { "statusCode": 201 }, "body": { "id": "rf_19", "status": "pending" } } } ] } ``` **Action** Inside `inputTemplate`, `{{name}}` in strings inserts an input or setting, and a whole-value `"/#amount"` inserts the input with its type intact. Set `json: true` to parse the response body as JSON; otherwise `body` is a string. > **Same rules as REST steps:** App actions behave like the workflow REST step: non-2xx responses don't fail the step, so check `/#steps..response.statusCode` in the next transition. See [Call your own code](https://developer.kustomer.com/docs/workflows/code-steps#handle-responses-and-failures) for status handling, timeouts and signing requests. A workflow step that uses the action names it and the app version range: ```json title="Workflow step" { "id": "refund", "action": "kustomer.app.acme_orders.refund", "appVersion": "acme_orders-^1.4.0", "params": { "orderId": "/#steps.1.orderId", "amount": "/#steps.1.total", "reason": "Requested in chat" }, "transitions": [] } ``` Give the action, and each input, a display name with [i18n](https://developer.kustomer.com/docs/apps/i18n#workflow-builder-strings). Without one, admins see the raw action name. ## Workflows: wire it together App workflows use the same [workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json) as any other, with two differences: names must be unique across every workflow in the org, and steps that use app actions carry `appVersion`. ```json title="workflows (excerpt)" { "workflows": [ { "name": "acme-orders-sync", "description": "Create or update an Acme order KObject when Acme sends an update.", "trigger": { "eventName": "kustomer.app.acme_orders.order-updated", "appVersion": "acme_orders-^1.4.0", "transitions": [{ "target": "findCustomer", "condition": { "op": "true", "values": [true] } }] }, "steps": [ { "id": "findCustomer", "action": "kustomer.customer.find-by-email", "appVersion": "kustomer-^1.9.0", "params": { "email": "/#steps.1.email" }, "transitions": [ { "target": "findOrder", "condition": { "op": "exists", "values": ["/#steps.findCustomer.id"] } } ] }, { "id": "findOrder", "action": "kustomer.kobject.find-by-external-id", "appVersion": "kustomer-^1.9.0", "params": { "klassName": "acme_orders-order", "externalId": "/#steps.1.orderId" }, "transitions": [ { "target": "updateOrder", "condition": { "op": "exists", "values": ["/#steps.findOrder.id"] } }, { "target": "createOrder", "condition": { "op": "true", "values": [true] } } ] }, { "id": "updateOrder", "action": "kustomer.kobject.update", "appVersion": "kustomer-^1.9.0", "params": { "id": "/#steps.findOrder.id", "klassName": "acme_orders-order", "title": "Order #{{steps.1.orderId}}", "data": {}, "custom": { "statusStr": "/#steps.1.status", "totalNum": "/#steps.1.total" } }, "transitions": [] }, { "id": "createOrder", "action": "kustomer.kobject.create-with-customer", "appVersion": "kustomer-^1.9.0", "params": { "customer": "/#steps.findCustomer.id", "klassName": "acme_orders-order", "externalId": "/#steps.1.orderId", "title": "Order #{{steps.1.orderId}}", "data": {}, "custom": { "orderNumberStr": "/#steps.1.orderId", "statusStr": "/#steps.1.status", "totalNum": "/#steps.1.total" } }, "transitions": [] } ] } ] } ``` Because a Kustomer action that finds nothing succeeds with an empty output, the `exists` checks route each run down the right branch. See [How Kustomer actions succeed and fail](https://developer.kustomer.com/docs/workflows/triggers-and-actions#how-kustomer-actions-succeed-and-fail). ### Read settings in workflows Use the `setting` helper in any template to read the installing org's values: ```json { "params": { "uri": "https://{{{ setting acme_orders.default.storeDomain }}}/api/orders" } } ``` Keep secrets out of workflow templates: workflow JSON and run logs are visible to admins. Reference secret settings from an action's `appSettings` instead, where they're filled in at request time. ### Let admins customize your workflows Admins often want to tweak an app workflow. Rather than editing it (an update to your app would overwrite their change), they copy it and set `replaces` on the copy to your workflow's name. While their copy is enabled, yours is skipped. See [Override an app workflow](https://developer.kustomer.com/docs/workflows/workflow-json#override-an-app-workflow-with-replaces). ### Build workflows in the editor first Writing large workflows by hand is slow. A faster loop: #### Register a version with your actions and triggers Ship `hooks`, `triggers`, `actions` and `settings`, without workflows. #### Build in the workflow builder Install it in a sandbox and build the workflow visually, using your app's actions and trigger. #### Export the JSON Read it with `kusty api GET /v1/workflows/ --output json`, remove the read-only attributes, and replace hard-coded values with `setting` helpers. #### Ship it Add it to `workflows`, give it a name unique to your app, bump the version and register. ## Outbound webhooks `outboundWebhooks` subscribes your service to Kustomer events in every org that installs the app, with no setup by the admin: ```json title="outboundWebhooks (excerpt)" { "outboundWebhooks": [ { "name": "Acme order sync", "url": "https://hooks.acme.example/kustomer", "events": ["kustomer.conversation.create", "kustomer.conversation.update"], "headers": [{ "name": "x-acme-source", "value": "kustomer" }] } ] } ``` **Outbound webhook** Every org posts to the same `url`. Use the `org` field in each payload to tell them apart. Payloads, signatures, retries and delivery history work the same as for any [outbound webhook](https://developer.kustomer.com/docs/events/outbound-webhooks). > **Public apps:** Kustomer reviews outbound webhooks closely before publishing an app: subscribe only to the events the app needs, and describe the data you receive in the app's `description`. --- # Settings > Declare per-org configuration such as API keys and subdomains, get an auto-generated settings page or embed your own, and read the values from actions, commands, workflows, widgets and KViews. Source: https://developer.kustomer.com/docs/apps/settings Settings hold what differs between the orgs that install your app: an API key, a store domain, a threshold, a feature toggle. You declare them in the definition; Kustomer generates a settings page for admins, stores the values per org, and fills them in wherever your app references them, server-side for secrets. ## Declare settings Settings are grouped into categories. Use `default` if you only need one: ```json title="settings (excerpt)" { "settings": { "default": { "apiKey": { "type": "secret", "defaultValue": "", "required": true }, "storeDomain": { "type": "string", "defaultValue": "", "required": true, "description": "Your store's domain, such as `shop.acme.example`." }, "refundLimit": { "type": "number", "defaultValue": 200 }, "createOrders": { "type": "boolean", "defaultValue": true }, "lastSyncCursor": { "type": "string", "defaultValue": "", "hidden": true } } } } ``` **settings.\.\** ### Secrets `secret` settings are write-only for people: admins can set them, but reading one through the API returns a placeholder. Only two features use the real value, both on Kustomer's servers: - **Workflow actions**, through `appSettings`. - **Commands**, through `appSettings`. Reference secrets only from those two. To use a key from a workflow, wrap the call in an app action. To use it from your UI, call your API through a [command](https://developer.kustomer.com/docs/apps/kviews#call-apis-with-commands). Never put a secret in a widget URL. ## Use settings Each feature has its own syntax. The key is always `..`, with your app's name as you wrote it; Kustomer adjusts it for private-app namespacing. **Actions and commands** Map settings to template variables with `appSettings`, then use them in the request: ```json { "appSettings": { "apiKey": { "key": "acme_orders.default.apiKey" }, "storeDomain": { "key": "acme_orders.default.storeDomain" } }, "inputTemplate": { "uri": "https://{{storeDomain}}/api/orders/{{orderId}}", "headers": { "Authorization": "Bearer {{apiKey}}" } } } ``` **Workflows** The `setting` helper works in any workflow template, with one space inside each set of braces: ```json { "uri": "https://{{{ setting acme_orders.default.storeDomain }}}/api/orders/{{steps.1.orderId}}" } ``` It resolves before the rest of the template renders. Use it for non-secret settings; secrets belong in an app action. See [Expressions reference](https://developer.kustomer.com/docs/workflows/expressions-reference#app-settings). **Widgets** ```json { "url": "https://kustomer.acme.example/widget?store={{{settings.default.storeDomain}}}" } ``` Widgets can only read their own app's settings. Don't reference secrets here: the URL is loaded in the agent's browser. **KViews** Templates receive the app's settings for the org as `appSettings`. Like everything in a template, they're visible in the agent's browser, so keep secrets out of them. **API** Read and write one setting: `GET /v1/settings/{app}/{category}/{name}` ([reference](https://developer.kustomer.com/api/settings-and-configurations/get-a-setting)) `PUT /v1/settings/{app}/{category}/{name}` ([reference](https://developer.kustomer.com/api/settings-and-configurations/update-a-setting)) ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/settings/acme_orders/default/lastSyncCursor" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"value": "c_20261021T1500"}' ``` ## The generated settings page When an app has `settings` and no `settingsPageConfig`, Kustomer builds a page under **Settings** with a field for each setting that isn't `hidden`, matched to its type. The page title, description, and each field's label and help text come from i18n keys: ```json title="i18n (excerpt)" { "i18n": { "en_us": { "acme_orders.settings.page.title": "Acme Orders", "acme_orders.settings.page.description": "Connect your Acme store to Kustomer.", "acme_orders.settings.path.default.apiKey.displayName": "API key", "acme_orders.settings.path.default.apiKey.description": "Create one in Acme under **Settings › API**.", "acme_orders.settings.path.default.storeDomain.displayName": "Store domain", "acme_orders.settings.path.default.refundLimit.displayName": "Largest refund agents can issue" } } } ``` Without these keys, admins see raw setting names. See [Internationalization](https://developer.kustomer.com/docs/apps/i18n#settings-page-strings). ## Build your own settings page For setup that needs more than form fields (validating a key against your API, choosing from a list your API returns, registering the org's hook URL with your platform) host your own page and point `settingsPageConfig` at it: ```json title="settingsPageConfig" { "settingsPageConfig": { "title": "Acme Orders", "description": "Connect your store and choose what to sync.", "url": "https://kustomer.acme.example/settings" } } ``` **settingsPageConfig** Your page replaces the generated one entirely, so it must load and save any settings itself. Load the [Cards SDK](https://developer.kustomer.com/docs/apps/kviews#the-cards-sdk); the page gets an `app` context and can call the API as the admin: ```js title="settings.js" const base = '/v1/settings/acme_orders/default' const valueOf = (res) => (res?.data ?? res)?.attributes?.value Kustomer.initialize(async () => { const domain = await Kustomer.request({ url: `${base}/storeDomain`, method: 'get' }) form.storeDomain.value = valueOf(domain) ?? '' Kustomer.resize() }) form.addEventListener('submit', async (e) => { e.preventDefault() const ok = await fetch('https://kustomer.acme.example/api/verify-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: form.storeDomain.value, key: form.apiKey.value }), }).then((r) => r.ok) if (!ok) return Kustomer.handleTriggerToast({ type: 'error', message: 'That key was rejected by Acme.' }) await Kustomer.request({ url: `${base}/storeDomain`, method: 'put', body: { value: form.storeDomain.value } }) await Kustomer.request({ url: `${base}/apiKey`, method: 'put', body: { value: form.apiKey.value } }) Kustomer.handleTriggerToast({ type: 'success', message: 'Saved' }) }) ``` | You define | Admins see | | ------------------------- | ------------------------------------------------------------------------------------- | | `settings` only | The generated page | | `settingsPageConfig` only | Your page | | Both | Your page. Settings still exist, and your page reads and writes them through the API. | | Neither | No settings page | > **Validate on your server:** Verify credentials from your backend, as above, not from the browser. Your page can then save the key into a `secret` setting so workflows and commands can use it. --- # Internationalization > The i18n key formats for app settings pages, widgets, workflow actions, action inputs and triggers, with locale fallback rules. Source: https://developer.kustomer.com/docs/apps/i18n The `i18n` object holds every string your app shows in Kustomer's own UI: settings page labels, widget titles, and the names of your actions, their inputs and your triggers in the workflow builder. Keys are grouped by locale; Kustomer shows the agent's locale when you provide it and falls back to `en_us`. ```json title="i18n (shape)" { "i18n": { "en_us": { "": "" }, "fr_fr": { "": "" } } } ``` Always provide `en_us`. Missing keys fall back to `en_us`, and with no `en_us` entry Kustomer shows the raw key or system name. Strings inside your KViews, widgets and custom settings pages are yours to translate; i18n covers only the UI Kustomer renders for you. ## Settings page strings `` is your app name as written in the definition. | Key | Labels | | --------------------------------------------------- | --------------------------------------------- | | `.settings.page.title` | The settings page heading and menu entry | | `.settings.page.description` | The text under the heading | | `.settings.path...displayName` | A setting's label | | `.settings.path...description` | A setting's help text. Markdown is supported. | ```json title="Settings strings" { "en_us": { "acme_orders.settings.page.title": "Acme Orders", "acme_orders.settings.page.description": "Connect your Acme store.", "acme_orders.settings.path.default.apiKey.displayName": "API key", "acme_orders.settings.path.default.apiKey.description": "Create one in Acme under **Settings › API**." } } ``` ## Widget strings | Key | Labels | | ---------------------------- | ---------------------------------------- | | `.widgets..title` | The title of the widget with that `name` | ## Workflow builder strings Actions and triggers are labelled under an `app..workflow.builder` prefix followed by their full, prefixed name: | Key | Labels | | --------------------------------------------------------------------------------- | ---------------------------------- | | `app..workflow.builder.actions..displayName` | The action in the step picker | | `app..workflow.builder.actions..description` | Its description | | `app..workflow.builder.actions..properties..displayName` | An input field's label | | `app..workflow.builder.actions..properties..exampleText` | An input's placeholder | | `app..workflow.builder.triggers..displayName` | The trigger in the trigger picker | | `app..workflow.builder.triggers..description` | Its description, shown when chosen | `` and `` are the full names, including `kustomer.app..`, so the app name appears twice: ```json title="Workflow builder strings" { "en_us": { "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.displayName": "Refund order", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.description": "Issue a full or partial refund in Acme.", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.properties.orderId.displayName": "Order ID", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.properties.orderId.exampleText": "4417", "app.acme_orders.workflow.builder.actions.kustomer.app.acme_orders.refund.properties.amount.displayName": "Amount", "app.acme_orders.workflow.builder.triggers.kustomer.app.acme_orders.order-updated.displayName": "Acme order updated", "app.acme_orders.workflow.builder.triggers.kustomer.app.acme_orders.order-updated.description": "Runs whenever Acme sends an order update. Later steps can use the order ID, customer email, status and total." } } ``` > **Private apps rewrite these keys:** When you register a private app, Kustomer appends your org ID to the app name inside action keys too, along with the action names themselves. Write keys with the plain app name; the registration response shows the rewritten ones. ## Keep keys in sync Keys are long and easy to mistype, and a typo fails silently: the builder just shows the raw name. Generate them from the definition in your build step instead of writing them by hand: ```js title="build-i18n.mjs" const app = 'acme_orders' const prefix = `app.${app}.workflow.builder` export function actionStrings(actions, labels) { const out = {} for (const action of actions) { const l = labels[action.name] ?? {} out[`${prefix}.actions.${action.name}.displayName`] = l.displayName ?? action.description if (l.description) out[`${prefix}.actions.${action.name}.description`] = l.description for (const input of Object.keys(action.inputSchema?.properties ?? {})) { const p = l.properties?.[input] ?? {} out[`${prefix}.actions.${action.name}.properties.${input}.displayName`] = p.displayName ?? input if (p.exampleText) out[`${prefix}.actions.${action.name}.properties.${input}.exampleText`] = p.exampleText } } return out } ``` And check for gaps before you register. This prints the label key of every setting that doesn't have one: ```bash jq -r ' .app as $app | .i18n.en_us as $t | .settings | to_entries[] | .key as $category | .value | keys[] | "\($app).settings.path.\($category).\(.).displayName" | select($t[.] == null) ' dist/acme_orders.json ``` --- # OAuth for apps > Use an app as an OAuth client of Kustomer so your service can act as a user, or configure auth.oauth2 so Kustomer connects to a third-party API on the admin's behalf. Source: https://developer.kustomer.com/docs/apps/oauth OAuth works in both directions on the apps platform: | You want | Kustomer is the | Configure | | ------------------------------------------------------------------------------------ | --------------- | ------------------------------------ | | Your hosted service to call the **Kustomer API** as the user who connected it | OAuth provider | `redirectUris`, plus a client secret | | Your app's actions and commands to call a **third-party API** with the admin's grant | OAuth client | `auth.oauth2` | ## Call Kustomer as a user Every app has a client ID. To let users sign in to your service with Kustomer and call the API on their behalf, declare your redirect URIs, register and install the app, and mint a client secret. ```mermaid sequenceDiagram autonumber participant Dev as You participant Apps as Kustomer participant User as Kustomer user Dev->>Apps: POST /v1/apps/available (with redirectUris) Apps-->>Dev: clientId User->>Apps: Install the app in their org Dev->>Apps: POST /v1/apps/available/{app}-{version}/secret Apps-->>Dev: clientSecret (shown once) User->>Apps: Authorize your service (OAuth authorization code flow) Apps-->>Dev: access_token + refresh_token ``` #### Declare redirect URIs ```json title="Definition" { "app": "acme_assistant", "version": "1.0.0", "title": "Acme Assistant", "visibility": "private", "redirectUris": ["https://assistant.acme.example/oauth/callback"] } ``` `title` is what users see on the consent screen, so make it recognizable. #### Register and install Register with `POST /v1/apps/available` (see [Register and install](https://developer.kustomer.com/docs/apps/register-and-install)). The response's `attributes.clientId` is your client ID. Then install the app in the org. Authorization fails with an `Invalid Client` error (`400`) in any org where the app isn't installed. #### Mint a client secret `POST /v1/apps/available/{app}-{version}/secret` ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/apps/available/acme_assistant_${ORG_ID}-1.0.0/secret" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` Private app names carry your org ID suffix, so use the registration response's `id`, which is `-`. The response contains the `clientSecret`, shown only this once; store it in your secrets manager. Calling the endpoint again generates a new secret. #### Run the authorization code flow Send the user to their org's authorize URL, exchange the code, and refresh tokens as described in [OAuth 2.0](https://developer.kustomer.com/docs/get-started/oauth). Request the roles you need as scopes, such as `org.permission.customer org.permission.conversation`, and add `offline_access` to get a refresh token. **Tokens** When an admin disables the app, Kustomer revokes the tokens it issued to the app's client. Users sign in again after it's re-enabled. ## Call third-party APIs with OAuth To call an API that uses OAuth 2.0 (a CRM, a commerce platform, a ticketing tool) add `auth.oauth2`. When an admin connects the app, Kustomer sends them through the provider's consent screen, stores the resulting token as the app's **app account**, keeps it refreshed, and injects it into every action and command that names that account. ```json title="auth (excerpt)" { "auth": { "oauth2": { "clientId": "acme_9f2c41", "clientSecret": "s3cr3t-from-acme", "clientCredentialsMethod": "basicAuth", "authorizationUrl": "https://acme.example/oauth/authorize", "tokenUrl": "https://api.acme.example/oauth/token", "authorizationScope": "orders:read refunds:write", "revokeUrl": "https://api.acme.example/oauth/revoke", "expiresIn": 3600 } }, "actions": [ { "name": "kustomer.app.acme_orders.refund", "type": "rest_api", "appAccountName": "default", "inputTemplate": { "uri": "https://api.acme.example/orders/{{orderId}}/refunds", "method": "POST", "json": true } } ], "commands": [ { "name": "acme_orders.app.lookup-order", "url": "https://api.acme.example/orders/{{{orderId}}}", "httpMethod": "get", "permittedArgs": ["orderId"], "appAccountName": "default" } ] } ``` **auth.oauth2** ### Register Kustomer's redirect URI with the provider Providers only redirect to URIs you've registered with them. Kustomer uses one static URI per app, so it works with providers that require an exact match: ```text https://api.apps.kustomerapp.com/v1/apps//authorize/oauth2/redirect ``` Replace `` with your app's name, including the `_` suffix for private apps. ### What admins see 1. An admin connects the app from its page in Kustomer, which opens the provider's consent screen with your `authorizationScope`. 2. After the admin approves, the provider redirects back with a code, which Kustomer exchanges at `tokenUrl`. The admin has 10 minutes to complete this before the attempt expires. 3. The token is stored, encrypted with the org's own key, as the app account named `default`. Each app has one app account. ### How the token is used Any action or command with `"appAccountName": "default"` gets the token added to its request: | `customHeader` | Header sent | | ---------------- | ------------------------------- | | Not set | `Authorization: Bearer ` | | `"X-Acme-Token"` | `X-Acme-Token: ` | If a request fails because the provider rejected the token, Kustomer records the error on the app account so the broken connection is visible. The admin fixes it by connecting again. Disabling the app deletes its OAuth connections, so admins must reconnect after re-enabling it. ### Without OAuth If the third-party API uses a static key instead, skip `auth` and store the key in a [`secret` setting](https://developer.kustomer.com/docs/apps/settings#secrets), referenced through `appSettings` in your actions and commands. --- # Versioning and updates > Semantic versioning rules for app definitions, what each bump means, changelogs, beta versions, upgrading installed apps, automatic updates, and editing listing details without a new version. Source: https://developer.kustomer.com/docs/apps/versioning Every registered version of an app is immutable. To change anything an org installs (components, settings, roles) you register a new version and orgs upgrade to it. Only listing details can change in place. ## Version numbers `version` is a [semantic version](https://semver.org): `MAJOR.MINOR.PATCH`, with an optional pre-release tag such as `1.5.0-beta`. | Rule | Error if broken | | --------------------------------------------- | --------------------------------------------------------------------------------------- | | Each version is registered once | `409` This version has already been registered. Please increment the version. | | A new version must be greater than the latest | `400` Cannot register outdated version. Version must be greater than X. | | Changing `roles` needs a new major version | `400` Roles do not match that of the previous version, a major version bump is required | ### Choose the bump | Bump | Use it for | Example | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | | **Major** | Anything an org must review: changed `roles`, removed components, renamed settings, changed action inputs, or new data sent to your servers | `1.4.2` → `2.0.0` | | **Minor** | New, backward-compatible components and settings | `1.4.2` → `1.5.0` | | **Patch** | Fixes that don't change the app's surface: template fixes, copy, KView layout | `1.4.2` → `1.4.3` | Major versions are never applied automatically, so admins always get to review them. For marketplace apps, a major version also gets a full review, including privacy. See [Publish to the marketplace](https://developer.kustomer.com/docs/apps/marketplace#updates). ## Changelog `changelog` maps versions to the release notes Kustomer shows admins on your app's page. Each entry starts with the release month in double braces, followed by Markdown: ```json title="changelog" { "changelog": { "1.5.0": "{{October 2026}} Added the **Refund order** workflow action.", "1.4.3": "{{September 2026}} Fixed the order timeline in dark mode.", "1.4.0": "{{August 2026}} Orders now sync on every update, not just on create." } } ``` Keys are versions, optionally with `-alpha` or `-beta`. Add an entry for each version you register, and say what changed for admins and agents, not how you built it. Marketplace apps must include release notes. `changelog` is app-wide, so you can fix a typo in an old entry without a new version; see [Edit without a new version](#edit-without-a-new-version). ## Beta versions Register a pre-release version such as `1.5.0-beta` to try a change in a sandbox org before it's final. Beta versions are never installed automatically: admins install them on purpose. When you're done, register the final version (`1.5.0`), which sorts after its betas. Marketplace listings can't contain betas. Test with a private app instead. ## Upgrade an installed app An org upgrades by installing the newer version. Admins do this from the app's page; with the API, call the install endpoint with the target version: `POST /v1/apps/install` ```json title="Body" { "app": "acme_orders", "version": "1.5.0" } ``` Omit `version` to install the latest. Kustomer compares the two versions and changes only what differs: | Component in the new version | What happens | | ---------------------------- | ---------------------------------------------------- | | New | Created | | Changed | Updated | | Removed | Deleted, **except Klasses**, which are never deleted | Klasses stay because deleting one would delete every record of that type. Their KObjects stay with them. Installs run in the background. Check progress with: `GET /v1/apps/install-state/{app}-{version}` `attributes.status` is `installing`, `installed` or `install_failed`, and `attributes.previousVersion` is the version you upgraded from. If an upgrade fails, the org keeps the previous version and you can retry. | Error | Meaning | | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `409` already installed | The org already has this version | | `409` currently installing | Another install of this version is in progress | | `409` Apps cannot be updated while they are deactivated | [Enable](https://developer.kustomer.com/docs/apps/register-and-install#disable-and-enable) the app first | > **Roll back with a new version:** To undo a bad release, register a new patch version with the previous behavior and upgrade to it. That keeps every org's version history moving forward. ## Automatic updates Admins can let an installed app update itself when you register a new version. The flag is per org, on the installed app: `PATCH /v1/apps/{app}` ```json title="Body" { "autoUpdate": true } ``` A new version is skipped, and left for the admin to install, when: - It's a **major** version. - It's a **pre-release**, such as `1.5.0-beta`. - The app is disabled in the org. - An install is already in progress. - An app it depends on isn't installed, or the installed version isn't one it supports. Treat automatic updates as a convenience for orgs, not a delivery guarantee: always assume some orgs are on older versions, and keep your hooks and APIs compatible with every version you still support. ## Edit without a new version Listing details that don't install anything can change in place. Two endpoints cover them: **One version** `PATCH /v1/apps/available/{app}-{version}` Changes the listing of a single version: `title`, `iconUrl`, `description`, `screenshots`, `instructions`, `releaseNotesUrl`, `postInstallMessage` and `auth`. ```bash title="cURL" curl -X PATCH "https://acme.api.kustomerapp.com/v1/apps/available/acme_orders_${ORG_ID}-1.5.0" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"description": "Sync Acme orders and issue refunds from Kustomer."}' ``` **Every version** `PUT /v1/apps/available/{app}-{version}` Changes app-wide fields for all versions: `appDetails`, `redirectUris`, `tags`, `changelog` and `auth`. ```bash title="cURL" curl -X PUT "https://acme.api.kustomerapp.com/v1/apps/available/acme_orders_${ORG_ID}-1.5.0" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"changelog": {"1.5.0": "{{October 2026}} Added the **Refund order** workflow action."}}' ``` Use these to rotate a third-party OAuth client secret in `auth`, or to add a redirect URI, without making every org upgrade. ## Version history at a glance ```mermaid gitGraph commit id: "1.0.0" commit id: "1.1.0 new KView" branch beta commit id: "1.2.0-beta" checkout main merge beta id: "1.2.0" commit id: "1.2.1 fix" commit id: "2.0.0 new roles" type: HIGHLIGHT ``` Orgs on automatic updates move from `1.0.0` to `1.2.1` without anyone acting. They stop at `2.0.0` until an admin approves the new roles. --- # Register and install > Register app definitions with the API, how names are namespaced, installing into an org and tracking progress, disabling and enabling, the read endpoints, and every registration error with its fix. Source: https://developer.kustomer.com/docs/apps/register-and-install Registering makes a version of your app available. Installing creates its components in an org. You register once per version; each org installs separately. ```mermaid flowchart LR A[acme_orders.json] -->|POST /v1/apps/available| B{Validate every component} B -->|fails| X[400 / 409, nothing created] B -->|passes| C[Registered version] C -->|POST /v1/apps/install| D{Validate in the org} D -->|fails| Y[install_failed, previous version kept] D -->|passes| E[Components created] E --> F[Admin configures settings and OAuth] ``` ## Before you start - **Use a sandbox org.** Registered versions are permanent and installed apps can't be fully uninstalled, so iterate somewhere disposable. - **Create an API key** in **Settings › Security › API Keys** with the `org.admin.apps` role. Registration and installs run as that key. - **Use the starter repo.** [`kustomer/kustomer-apps-starter-configs`](https://github.com/kustomer/kustomer-apps-starter-configs) has example definitions and an `npm run register-new-version` script. Put `API_BASE_URL` and `API_TOKEN` in `.env`. ## Register a version `POST /v1/apps/available` The body is the whole app definition. ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/apps/available" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ --data @dist/acme_orders.json ``` ```bash title="kusty" kusty api POST /v1/apps/available --data @dist/acme_orders.json ``` ```js title="Node" const definition = JSON.parse(await readFile('dist/acme_orders.json', 'utf8')) const res = await fetch(`${process.env.API_BASE_URL}/v1/apps/available`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify(definition), }) if (!res.ok) { const { errors } = await res.json() for (const e of errors) console.error(e.status, e.title, e.source) process.exit(1) } ``` Kustomer validates the definition against its schema, then dry-runs every component against the service that will own it: each workflow, KView, Klass and hook. If anything fails, nothing is registered. The `201` response is an `available_app` whose `id` is `-`, with the final name in `attributes.name` and the OAuth client ID in `attributes.clientId`. > **For agents fixing a failed registration:** Read each error's `title` and `source`: `source` names the field or component that failed. Fix that one thing, bump nothing, and register again. A rejected version was never registered, so you can reuse its number. ## Names are namespaced Kustomer rewrites component names so apps can't collide with each other or with an org's own configuration. You can write the full names yourself or leave them short; registration applies the scheme either way. | Component | Field | Registered as | | -------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Actions | `name` | `kustomer.app..` | | Hooks | `eventName` | `kustomer.app..` | | KViews | `name` | `kustomer.app..` | | Triggers | `eventName` | `kustomer.app..` | | Workflow steps | `action`, `appVersion` | `kustomer.app..`, `-` | | Workflow triggers | `eventName`, `appVersion` | `kustomer.app..`, `-` | | Commands | `name` | `.app.` | | Settings references | `appSettings` keys | `..` | | Klasses | `name` | **Not rewritten.** Use a prefix such as `acme_orders-order` yourself. | | Standard-object attributes | attribute name | `@`. See [Klasses](https://developer.kustomer.com/docs/apps/klasses#attributes-are-namespaced). | ### Private apps get your org ID Private app names must be unique across Kustomer, so registration appends your org ID: `acme_orders` becomes `acme_orders_`, and every rewritten name above uses the suffixed form, such as `kustomer.app.acme_orders_.refund`. Keep writing the short name in your definition and read the final one from `attributes.name` in the registration response. Use the final name in API calls that take an app name: installs, version IDs (`acme_orders_-1.5.0`) and the redirect URI for [third-party OAuth](https://developer.kustomer.com/docs/apps/oauth#call-third-party-apis-with-oauth). ### The identifier `identifier` is a short, lowercase code (2 to 8 characters, `a-z`, `0-9`, `_`) used to prefix your attributes on standard objects. Kustomer generates one if you leave it out. It can't change after your first registration, so pick it deliberately if you add attributes to customers or conversations. ## Install in an org Admins install from **Settings › Apps › App Directory**. To install with the API, use a key from the target org: `POST /v1/apps/install` ```bash title="cURL" curl -X POST "https://acme.api.kustomerapp.com/v1/apps/install" \ -H "Authorization: Bearer $KUSTOMER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"app": "acme_orders_", "version": "1.5.0"}' ``` **Body** The response is an `app_install_state` with `status: "installing"`. Installs run in the background, so poll until they finish: `GET /v1/apps/install-state/{app}-{version}` ```json title="Response (excerpt)" { "data": { "type": "app_install_state", "attributes": { "app": "acme_orders_", "version": "1.5.0", "previousVersion": "1.4.3", "status": "installed", "startedAt": "2026-10-21T15:02:11.204Z", "endedAt": "2026-10-21T15:02:19.871Z" } } } ``` | `status` | Meaning | | ---------------- | -------------------------------------------------------------------------------------- | | `installing` | Components are being created or updated | | `installed` | Done. The version is now the org's current version. | | `install_failed` | Something failed. The org keeps its previous version; fix the cause and install again. | Installing runs the same dry-run as registration, this time in the org. It catches conflicts the org brings, such as a Klass or KView name the org already uses. After installing, admins fill in [settings](https://developer.kustomer.com/docs/apps/settings) and connect any [OAuth account](https://developer.kustomer.com/docs/apps/oauth#call-third-party-apis-with-oauth). Upgrades use the same endpoint with a newer version; see [Versioning and updates](https://developer.kustomer.com/docs/apps/versioning#upgrade-an-installed-app). ## Read apps | Endpoint | Returns | | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | [`GET /v1/apps/available`](https://developer.kustomer.com/api/apps-platform/get-available-apps) | Apps your org can install | | [`GET /v1/apps/available/{appVersion}`](https://developer.kustomer.com/api/apps-platform/get-available-app) | One registered version, as `-` | | [`GET /v1/apps`](https://developer.kustomer.com/api/apps-platform/get-installed-apps) | Apps installed in your org | | [`GET /v1/apps/{appVersion}`](https://developer.kustomer.com/api/apps-platform/get-installed-apps-by-specific-version) | One installed app. Pass `` for the current version, or `-` for the highest installed version matching a semver range. | ## Disable and enable Disabling an app turns it off in the org without removing it. `POST /v1/apps/{app}/disable` `POST /v1/apps/{app}/enable` | | Disable | Enable | | ------------------------------------------------ | -------------------------------------------------------- | ----------------------------------- | | Components | Turned off | Turned back on | | Third-party OAuth connections | Deleted. Kustomer calls your `revokeUrl` if you set one. | Not restored. Admins connect again. | | Tokens Kustomer issued to the app's OAuth client | Revoked | Not restored. Users sign in again. | | Data | Kept: KObjects, attribute values and settings stay | | | Upgrades | Blocked until enabled | | The response is `204`, or `200` with `data.warning` when revoking a third-party token failed. The disable still succeeded. ## Uninstalling There's no API to fully uninstall an app. Disable it to turn it off; its Klasses and their records stay in the org. That's the main reason to develop in a sandbox org, and to iterate on a definition there before you install it anywhere that matters. ## Troubleshooting | Error | Cause | Fix | | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` using deprecated fields! use appDetails.(appDeveloper \| externalPlatform).(name \| website) instead | Top-level `vendor` or `website` | Move them into `appDetails.appDeveloper` or `appDetails.externalPlatform` | | `400` naming an unknown property | A top-level key that isn't in the schema | Check spelling against [App definition](https://developer.kustomer.com/docs/apps/app-definition#identity-and-listing); unknown keys are rejected, not ignored | | `400` on `app` or `identifier` | Invalid characters or length | `app`: letters, digits, `_`, up to 128 characters. `identifier`: lowercase letters, digits, `_`, 2 to 8 characters. | | `400` on `visibility` | `"public"` | Register with `"private"`. Public apps go through the [marketplace](https://developer.kustomer.com/docs/apps/marketplace). | | `409` The app name is already taken. Please choose a different name. | Another app uses the name | Pick another `app` | | `409` This version has already been registered. Please increment the version. | Re-registering a version | Bump `version` | | `400` Cannot register outdated version. Version must be greater than X. | `version` is lower than the latest | Use a version above X | | `400` Roles do not match that of the previous version, a major version bump is required | `roles` changed in a minor or patch release | Bump the major version | | `400` with a `source` inside a component | That workflow, KView, hook or Klass failed its dry run | Fix the component. Building workflows in the editor first and exporting them avoids most of these; see [Workflows, actions and triggers](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#build-workflows-in-the-editor-first). | | `install_failed` on install | Usually a name clash with the org's own resources, such as an existing Klass | Check `attributes.components` in the install state to find the component, then rename it in a new version | | `409` Apps cannot be updated while they are deactivated | Installing into an org where the app is disabled | Enable it, then install | | `409` currently installing | An install of that version is still running | Wait for the install state to finish | --- # Publish to the marketplace > How to get a public app listed in the Kustomer App Directory, including listing assets, review requirements, the data and security questionnaire, timelines, and how updates are reviewed. Source: https://developer.kustomer.com/docs/apps/marketplace Public apps appear in every org's **App Directory**. You don't register them yourself: you build and test a private app, then submit its definition, and Kustomer publishes the public version after review. ```mermaid flowchart LR A[Build as a private app] --> B[Test in a sandbox org] B --> C[Submit the definition and listing] C --> D[Kustomer review
4 to 6 weeks] D -->|changes requested| B D -->|approved| E[Published in the App Directory] ``` ## Build it private first Develop the whole app as `"visibility": "private"` in a sandbox org. When you submit, Kustomer registers it as public under your plain app name, so write the definition with short names and no org IDs. See [Names are namespaced](https://developer.kustomer.com/docs/apps/register-and-install#names-are-namespaced). Your submission is a **single JSON file**. If you split the definition across files, ship the built output. ## Listing assets | Asset | Requirements | | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Logo | PNG, 1200 × 1200 px. Corners are rounded for you, so don't round them yourself. | | Screenshots | `.jpg`. Show the app working in Kustomer: KViews, widgets, the settings page, workflow actions in the builder. | | `title` | The product name admins will search for | | `description` | What it does, and what data goes to third parties: what is sent, when, and why. Include subscription or pricing requirements. | | `appDetails` | Your company (`appDeveloper`) and, if different, the platform you integrate (`externalPlatform`), each with a name and website | | `instructions` | How to set it up, including how to get any keys the settings ask for | | `changelog` | Release notes for every version. See [Changelog](https://developer.kustomer.com/docs/apps/versioning#changelog). | ## Review checklist Apps that miss any of these are sent back. - [ ] **Fully tested** in a Kustomer org, every component and every setting. - [ ] **No beta versions.** Use private apps for beta testing. - [ ] **No placeholders**: no lorem ipsum, test URLs, or "coming soon" settings. - [ ] **No hidden features.** Everything the app does is described in the listing. - [ ] **Works in dark mode.** Check KViews, widgets and custom settings pages. See [Support dark mode](https://developer.kustomer.com/docs/apps/kviews#support-dark-mode). - [ ] **No manual Kustomer setup** beyond the app's settings page. The definition must create everything the app needs. - [ ] **No installing components through the API.** Workflows, Klasses and other components come from the definition, not from your server calling the Kustomer API after install. - [ ] **No manual setup in the external platform.** If admins must authorize access there, use [OAuth](https://developer.kustomer.com/docs/apps/oauth#call-third-party-apis-with-oauth). - [ ] **Demo account provided** for the external platform, so reviewers can test end to end. - [ ] **Data disclosure** in the description: what goes to third parties, when and why. - [ ] **Release notes** for the submitted version. ## Data and security questionnaire Every submission includes a questionnaire about how the app handles customer data. Expect questions about: - **Privacy**: what data you store, where, for how long, and who can access it. - **Certifications**: SOC 2, ISO 27001 or similar, if you have them. - **Security**: encryption in transit and at rest, access controls, and incident response. Apps that send data out of Kustomer, such as with [outbound webhooks](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#outbound-webhooks), get the closest scrutiny, so describe each flow and why the app needs it. ## Submit 1. Finish the checklist above. 2. Fill in the [marketplace submission form](https://forms.gle/u5vWAm7dgVx68iQY7) with your listing details, the questionnaire, and demo credentials. 3. Attach the definition JSON, logo and screenshots. Review takes **4 to 6 weeks**. Kustomer may come back with changes, and you resubmit after fixing them. ## Updates Every update is a new version, reviewed in proportion to what changed: | Change | Review | | ------------------------------------------ | ------------------------------------------------- | | **Major** (`2.0.0`) | Full review again, including privacy and security | | **Minor** (`1.5.0`) or **patch** (`1.4.3`) | Review of what changed in the version | | **Critical bug fix** | Expedited. Say it's critical when you submit. | Changing `roles` always needs a major version. See [Versioning and updates](https://developer.kustomer.com/docs/apps/versioning#choose-the-bump) for which bump to use. --- # Legacy Apps SDK > Reference for the deprecated TypeScript Apps SDK (KAppServer, the client and React SDKs, and the apps CLI), and how to migrate an SDK app to a JSON app definition. Source: https://developer.kustomer.com/docs/apps/legacy-apps-sdk > **Deprecated:** The Apps SDK is no longer developed. It's kept running for existing apps, but new apps should use [JSON app definitions](https://developer.kustomer.com/docs/apps/app-definition), which Kustomer runs for you with no server to host. The Apps SDK was a TypeScript framework for building Kustomer apps as a hosted service. You wrote an Express server with `KAppServer`, which generated the app definition from code, registered it, received events and webhooks, and served client-side KViews. ## Packages | Package | What it did | | ----------------------------- | -------------------------------------------------------------------------------------------------- | | `@kustomer/apps-server` | `KAppServer`, an Express app that registers the definition and routes hooks and events to handlers | | `@kustomer/apps-client` | A framework-agnostic library for client-side KViews | | `@kustomer/apps-react-client` | React components for KViews in Kustomer's design system, built on `apps-client` | | `@kustomer/apps-common` | Shared types (such as `SettingsManifest`) and API helpers used by the client and server | | `@kustomer/apps-tokens` | Design tokens for the React components | | `@kustomer/apps-cli` | Scaffolds new apps (`npx @kustomer/apps-cli new `) and registers them | ## How an SDK app looks ```ts title="src/kapp.ts" import { KAppServer } from '@kustomer/apps-server' import pkg from '../package.json' import changelog from '../changelog.json' import { settings } from './settings' import * as klasses from './klasses' import * as events from './events' const kapp = new KAppServer({ app: pkg.name, version: pkg.version, title: 'Acme Orders', visibility: 'private', description: 'Orders from Acme, on the customer timeline.', roles: [], url: process.env.BASE_URL, clientId: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, iconUrl: `${process.env.BASE_URL}/assets/icon.png`, changelog, appDetails: { appDeveloper: { name: 'Acme', website: 'https://acme.example', supportEmail: 'support@acme.example' }, }, settings, }) kapp.onInstall = async (user, org) => { kapp.log.info('onInstall', { user, org }) } kapp.klasses.register(klasses.order.name, klasses.order.schema) kapp.serverHooks.on('order', klasses.order.onHook) kapp.events.on('customer', 'create', events.customer.create(kapp)) export default kapp ``` ```ts title="src/index.ts" import kapp from './kapp' await kapp.start({ port: Number(process.env.PORT ?? 3000) }) ``` The constructor options are the same top-level keys as a JSON definition, plus the URL your server is reachable at. `settings` uses the same shape as a definition's [`settings`](https://developer.kustomer.com/docs/apps/settings). ## Migrate to an app definition Most SDK apps translate directly. The work is moving logic that ran in your handlers into Kustomer workflows, actions and commands, so that Kustomer runs it instead of your server. | SDK | App definition | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `new KAppServer({ app, version, title, … })` | The same keys at the top level. See [App definition](https://developer.kustomer.com/docs/apps/app-definition). | | `settings` | [`settings`](https://developer.kustomer.com/docs/apps/settings), unchanged | | `kapp.klasses.register(name, schema)` | [`klasses`](https://developer.kustomer.com/docs/apps/klasses) | | `kapp.serverHooks.on(name, handler)` | A [`hook`](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#hooks-get-data-in) and [`trigger`](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#triggers-name-the-event), with a [workflow](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#workflows-wire-it-together) doing what the handler did | | `kapp.events.on(resource, event, handler)` | A workflow triggered by the [Kustomer event](https://developer.kustomer.com/docs/events/event-catalog) that calls your API through an [action](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#actions-call-your-api), or an [outbound webhook](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#outbound-webhooks) | | Commands your handlers called | [`commands`](https://developer.kustomer.com/docs/apps/kviews#call-apis-with-commands) | | Client-side KViews (`apps-client`, `apps-react-client`) | [`kviews`](https://developer.kustomer.com/docs/apps/kviews) with JSX templates, or your own page in a [`DynamicCard`](https://developer.kustomer.com/docs/apps/kviews#embed-your-own-ui) | | `kapp.onInstall` | Usually not needed: the definition creates everything on install. Put remaining setup in the settings page or a workflow. | | The CLI's `register` | `POST /v1/apps/available`. See [Register and install](https://developer.kustomer.com/docs/apps/register-and-install). | #### Export what the SDK registers Fetch your latest registered version with [`GET /v1/apps/available/{appVersion}`](https://developer.kustomer.com/api/apps-platform/get-available-app). Copy its listing details, `roles`, `settings` and `appDetails` into your JSON file as a starting point. #### Replace each handler Work through the table above. Build each workflow in the workflow editor, test it, then export it into the definition. See [Build workflows in the editor first](https://developer.kustomer.com/docs/apps/workflows-actions-triggers#build-workflows-in-the-editor-first). #### Keep names and Klasses Keep the same `app` name and Klass names so installed orgs keep their data. Klasses aren't deleted on upgrade, so existing records carry over. #### Ship a major version Register the definition as a new **major** version: it removes your server from the app, and orgs should review the change. Once every org has upgraded, you can retire the server. > **For agents migrating an SDK app:** Read the `KAppServer` options first: they map one-to-one onto top-level definition keys. Then search the code for `klasses.register`, `serverHooks.on`, `events.on` and `onInstall` to find the components and logic that have to move into the definition. --- # Chat SDKs > Put Kustomer chat and the knowledge base in your website and mobile apps. Pick an SDK, compare what each platform supports, and see how identity, push and the server-side chat API fit together. Source: https://developer.kustomer.com/docs/chat Kustomer Chat connects customers in your website or app with agents and AI assistants in Kustomer. Conversations are real Kustomer conversations: they show up in the agent's queue, run through your workflows and routing, and follow the customer across devices once you identify them. Each SDK comes in two layers. The **UI** layer is a complete chat and knowledge base experience you can open with one call and style to your brand. The **core** layer is the headless API under it, for when you want to build your own interface. - [Web](https://developer.kustomer.com/docs/chat/web): One script tag for the chat widget, or the Core API for a custom UI in any framework. - [iOS](https://developer.kustomer.com/docs/chat/ios): The `KustomerChat` Swift module, through Swift Package Manager or CocoaPods. - [Android](https://developer.kustomer.com/docs/chat/android): `com.kustomer.chat:ui` and `:core` on Maven Central, written in Kotlin. - [React Native](https://developer.kustomer.com/docs/chat/react-native): `@kustomer/chat-react-native`, wrapping the native iOS and Android SDKs. Not building a customer-facing UI? To bring conversations from your own chat system or bot into Kustomer, use the [server-side chat API](https://developer.kustomer.com/docs/chat/server-chat-api). ## Latest versions | SDK | Package | Version | Minimum platform | | ------------ | ------------------------------------------------ | -------------- | ----------------------------------- | | Web widget | `https://cdn.kustomerapp.com/chat-web/widget.js` | Always current | — | | Web Core | `https://cdn.kustomerapp.com/chat-web/core.js` | Always current | — | | iOS | `kustomer-ios-spm` or `pod 'KustomerChat'` | 8.3.0 | iOS 15 | | Android | `com.kustomer.chat:ui` | 4.6.2 | API 24, Kotlin 2.2 toolchain | | React Native | `@kustomer/chat-react-native` | 6.3.6 | React Native 0.77, iOS 15.1, API 24 | ## What each platform supports | Capability | Web widget | Web Core | iOS | Android | React Native | | ----------------------------------------- | ---------------------------------- | -------- | --------------------- | ----------------- | ------------------------ | | Prebuilt chat and knowledge base UI | Yes | — | Yes | Yes | Yes (native UI) | | Headless API for your own UI | — | Yes | Yes | Yes | Create conversation only | | Open a specific conversation | Yes | — | Yes | Yes | Yes | | Start a conversation with a first message | Yes | Yes | Yes | Yes | Yes | | AI assistants | Yes | Yes | Yes | Yes | Yes | | Identify customers with a JWT | Yes | Yes | Yes | Yes | Yes | | Customer and conversation attributes | Yes | Yes | Yes | Yes | Yes | | Unread count | Yes | Yes | Yes | Yes | Yes | | Availability (business hours) | Yes | Yes | Yes | Yes | Yes | | Typing indicators in your own UI | — | Yes | Yes | Yes | — | | Knowledge base in your own UI | — | Yes | Yes | Yes | — | | Satisfaction surveys in your own UI | — | Yes | — | Yes | — | | Attachments | Yes | Yes | Yes | Yes | Yes (native UI) | | Import a conversation (handoff) | Yes | Yes | Yes | Yes | Yes | | Push notifications | — | — | APNs | FCM | APNs and FCM | | Theme colors | Kustomer settings | Your CSS | Asset catalog or code | Theme attributes | Native, per platform | | Override UI strings | Greeting and messages, in Kustomer | Your UI | `Kustomer.strings` | `kus_*` resources | Native, per platform | | Link previews | Yes | Yes | Yes | Yes | Yes | | History status filter | Yes | — | Yes | Yes | Yes | Where a capability says "your own UI", the prebuilt UI already handles it; the column shows whether the headless API exposes it for custom builds. ## How the pieces fit #### Configure chat in Kustomer Admins set chat's appearance, greeting, business hours, assistants and knowledge base per brand in **Apps › Chat**. The SDKs load these settings at startup, so most changes need no app release. #### Create a chat key Every SDK uses an API key with **only** the `org.tracking` role. It's meant to ship in your page or app, because that role only covers what a chat customer can do. Don't add other roles to it; the mobile SDKs reject keys that have them. #### Add the SDK and open chat Follow the quickstart for your platform. Out of the box, chat works for anonymous visitors. #### Identify signed-in customers Your server signs a short-lived JWT, and the SDK logs in with it, so conversations attach to the customer's profile and appear on all their devices. See [Customer identity](https://developer.kustomer.com/docs/chat/identity). #### Add push on mobile Upload your APNs key or Firebase service account in Kustomer and forward tokens from your app. See [Push notifications](https://developer.kustomer.com/docs/chat/push-notifications). For what happens between the SDK and Kustomer, with HTTPS requests for writes and a realtime channel for messages, typing and presence, see [How chat works](https://developer.kustomer.com/docs/chat/realtime-architecture). ## Choosing between UI and core | Use the prebuilt UI when | Use core when | | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | You want chat live quickly, with Kustomer's tested UX | Chat has to look and behave exactly like the rest of your product | | Admins should control branding and copy without releases | You're embedding chat inside an existing screen rather than a sheet | | You need the knowledge base, CSAT, attachments and assistants without extra work | You only need part of chat, such as unread counts or starting conversations | You can mix them: many apps use the prebuilt UI and only call core for unread counts or to start a conversation from a help button with context attached. > **For AI agents:** Every chat page is available as Markdown by appending `.md` to its URL, and [`/llms.txt`](https://developer.kustomer.com/llms.txt) indexes them. When generating integration code, use the versions and method signatures on these pages; older examples elsewhere often use deprecated calls such as `Kustomer.configure` on iOS or callback overloads that no longer exist on Android. --- # How chat works > The architecture shared by every Kustomer chat SDK. The UI and Core layers, chat keys and customer identity, HTTPS for writes and history, the realtime channel for delivery, and the conversation lifecycle. Source: https://developer.kustomer.com/docs/chat/realtime-architecture Every Kustomer chat SDK (web, iOS, Android and React Native) has the same shape: a **UI** layer on top of a headless **Core**, talking to Kustomer over HTTPS for anything it sends and over a realtime channel for anything Kustomer pushes back. _Animated diagram of a chat message moving between the chat UI, the Core SDK, the HTTPS and realtime transports, Kustomer, and the agent. Scenarios show a customer sending, an agent replying, an agent typing, and reconnecting after going offline._ ## Two layers | Platform | UI layer | Core layer | | ------------ | ---------------------------------- | ------------------------------------ | | Web | `widget.js`, the `Kustomer` global | `core.js`, the `KustomerCore` global | | iOS | `KustomerChat` | `KustomerCore` | | Android | `com.kustomer.ui` (`:ui`) | `com.kustomer.core` (`:core`) | | React Native | Native UI on each platform | Native Core on each platform | **Core** owns identity, conversations, messages, the realtime connection, unread counts and the knowledge base. **UI** renders it: the launcher, conversation list, message thread, CSAT and knowledge base screens. The UI only talks to Kustomer through Core, so if you build your own UI on Core, you get exactly the same behavior. On the web, both layers run in their own iframes. Your page's CSS and JavaScript can't reach into chat, and chat's can't leak onto your page. ## Identity Every SDK starts with a **chat key**: an API key whose only role is `org.tracking`. It's meant to be public. With it, the SDK can do one thing: chat as the customer on that browser or device. ```mermaid stateDiagram-v2 direction LR [*] --> Anonymous: SDK starts with the chat key Anonymous --> Anonymous: Customer chats (a customer record is created) Anonymous --> Identified: login(jwt signed by your server) Identified --> Identified: Same customer on every device Identified --> Anonymous: logout() ``` 1. **Anonymous.** The SDK creates an identity for the browser or device and stores it locally, so conversations survive reloads and app restarts. When the visitor first chats, Kustomer creates a customer for them. 2. **Identified.** When your user signs in, your server signs a short-lived JWT with their email or your user ID, and the SDK calls `login` with it. Kustomer attaches the session to that customer, so their conversations follow them across browsers and devices. 3. **Logged out.** `logout` clears the local session. The next visitor on the device starts anonymous. Never identify a customer from the client alone: anyone could claim to be anyone. The JWT is signed with a secret only your server knows. See [Customer identity](https://developer.kustomer.com/docs/chat/identity). ## Transport | Direction | Transport | Carries | | -------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | | SDK → Kustomer | HTTPS | Creating conversations, sending messages and attachments, read receipts, CSAT, identity, settings, history | | Kustomer → SDK | Realtime channel | New messages, typing indicators, read state, conversation ended or reopened, merges, deletes | The realtime channel is a subscription to channels for the customer and their conversations, set up after `init` and again after `login` or creating a conversation. The SDKs never poll for messages. History is read from the realtime channel first, for recent messages, and from Kustomer over HTTPS for anything older or missing, such as after the device was offline. Typing indicators are **signals**: they're delivered live and never stored, so they're absent from history. ## Conversation lifecycle ```mermaid stateDiagram-v2 direction LR [*] --> Assistant: Brand has an assistant [*] --> Open: No assistant Assistant --> Open: Assistant hands off Open --> Ended: Agent or customer ends it Ended --> Open: Reopened Ended --> CSAT: Survey sent CSAT --> [*] ``` | State | Core event | | -------------------------------------------- | ------------------------ | | Conversation created | `onConversationCreate` | | Assistant finished, waiting for an agent | `onAssistantEnded` | | Ended | `onConversationEnded` | | Reopened | `onConversationUnended` | | CSAT survey arrived | `onSatisfactionReceived` | | Merged with another conversation in Kustomer | `onConversationMerged` | On the Kustomer side, a chat conversation is a normal [conversation](https://developer.kustomer.com/docs/concepts/conversations-and-messages) on the `chat` channel, and each chat message is a message on it. Everything else in Kustomer, including [workflows](https://developer.kustomer.com/docs/workflows), routing, [webhooks](https://developer.kustomer.com/docs/events/outbound-webhooks) and the [REST API](https://developer.kustomer.com/api), sees chat like any other channel. To act as a participant from your server, use the [server-side chat API](https://developer.kustomer.com/docs/chat/server-chat-api). ## Settings come from Kustomer The SDK fetches the brand's chat settings when it starts: colors, greeting, business hours, assistant, knowledge base, attachments and page rules. Admins change chat for every platform at once without an app release. Your code chooses the brand and handles placement and identity. ## Where to go next - [Web quickstart](https://developer.kustomer.com/docs/chat/web): Two script tags and `Kustomer.start`. - [Customer identity](https://developer.kustomer.com/docs/chat/identity): Sign JWTs and log customers in on every platform. - [Server-side chat API](https://developer.kustomer.com/docs/chat/server-chat-api): Answer chats from your own bot or AI agent. --- # Web quickstart > Embed Kustomer Chat on a website with widget.js, start it with Kustomer.start, control it from your code, and listen for widget events. Includes every start option and widget method. Source: https://developer.kustomer.com/docs/chat/web The web SDK ships as a script you load from Kustomer's CDN. It comes in two builds: | Build | Script | Global | Use it to | | ---------- | ----------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **Widget** | `widget.js` | `Kustomer` | Drop in the full chat and knowledge base UI, and control it from your code. Most sites want this. | | **Core** | `core.js` | `KustomerCore` | Build your own chat UI on the same API the widget uses. See [Web Core API](https://developer.kustomer.com/docs/chat/web-core-api). | This page covers the widget. ## Embed the widget #### Get a chat key In Kustomer, go to **Settings › Security › API Keys** and create a key with **only** the `org.tracking` role. Chat keys are safe to put in a web page because that role can do nothing but chat as an anonymous or logged-in customer. See [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles). #### Add the script Put this before `` on every page that should have chat: ```html title="index.html" ``` The loader reads the key from `data-kustomer-api-key`, defines `window.Kustomer` with only `start`, requests the rest of the SDK, and fires `kustomerLoaded` on `window` straight away. The `start` call is queued until the SDK arrives. Call every other method from the `start` callback or later. #### Pick a brand `brandId` chooses which chat settings to use: colors, greeting, business hours and assistant. List your brands and their IDs with [`GET /v1/brands`](https://developer.kustomer.com/api/core-resources/get-brands). Leave it out to use the default brand. #### Test it Load the page, open chat and send a message. It appears in Kustomer as a new chat conversation on an anonymous customer. Reply from Kustomer and watch it arrive in the widget. To try your key and brand before touching your site, use the [playground](#playground) below. _Figure: The Kustomer chat widget open in the bottom-right corner of a website, showing a conversation with an agent. The widget runs in its own iframe, so your page's CSS and scripts can't break it, and it can't break them._ ### Single-page apps Load the script once for the lifetime of the app, not on every route change, and keep `Kustomer.start` out of render paths: ```tsx title="ChatProvider.tsx" import { useEffect } from 'react' declare global { interface Window { Kustomer?: any } } export function ChatProvider({ brandId }: { brandId: string }) { useEffect(() => { if (document.querySelector('[data-kustomer-api-key]')) return const onLoaded = () => window.Kustomer.start({ brandId }) window.addEventListener('kustomerLoaded', onLoaded, { once: true }) const script = document.createElement('script') script.src = 'https://cdn.kustomerapp.com/chat-web/widget.js' script.dataset.kustomerApiKey = import.meta.env.VITE_KUSTOMER_CHAT_KEY document.body.appendChild(script) }, [brandId]) return null } ``` To tear chat down, for example when a user switches accounts, call `Kustomer.stop()`. It removes the widget iframe, and a later `Kustomer.start()` builds it again. ### Shopify On Shopify storefronts the loader picks up `window.Shopify.shop` automatically. Elsewhere, pass the store domain with `data-kustomer-shop="acme.myshopify.com"` on the script tag. ### Kustomer knowledge base To show chat on a knowledge base hosted by Kustomer, add the loader to a custom theme. The default theme doesn't accept new files, so duplicate it first. #### Add a global script In Kustomer, open **Settings › Knowledge Base › Themes**, choose your custom theme, and in **Theme Files** select **Add**. Create a file with **Theme Target** `Global`, **File Name** `chat` and **File Type** `JS`. #### Load the widget from it Paste the loader into `chat.js`, save, and apply the theme: ```js title="chat.js" window.addEventListener('DOMContentLoaded', function () { var script = document.createElement('script') script.src = 'https://cdn.kustomerapp.com/chat-web/widget.js' script.setAttribute('data-kustomer-api-key', 'YOUR_CHAT_KEY') document.body.appendChild(script) }) window.addEventListener('kustomerLoaded', function () { Kustomer.start({ brandId: 'YOUR_BRAND_ID' }) }) ``` Use the brand that owns the knowledge base, so chat and articles share the same branding. ## Playground Run the real widget here with your own chat key. It loads into an example storefront, so you can check the key, brand, assistant and start options, call any widget method, and watch the events your code would receive. The **Embed code** tab gives you the snippet for the options you picked. _Interactive playground: paste a chat key and brand ID to load the real Kustomer chat widget into an example storefront, run widget methods such as open, createConversation and getOpenConversations, watch widget events in a live log, and copy the embed code for the chosen start options._ The playground refuses keys with any role besides `org.tracking`, since only chat keys are safe in a web page. If your brand uses page rules, add one that matches this site, or the launcher stays hidden here. See [Customization and security](https://developer.kustomer.com/docs/chat/web-customization). ## Start options `Kustomer.start(options, callback)` builds the widget. The callback runs once it's ready, and methods that need a started widget, such as `getOpenConversations`, must run after it. ```js title="Every common option" Kustomer.start( { brandId: 'YOUR_BRAND_ID', assistantId: 'YOUR_ASSISTANT_ID', scheduleId: 'YOUR_SCHEDULE_ID', lang: 'fr', preferredView: 'chat', chatIconPosition: { alignment: 'left', horizontalPadding: 24, verticalPadding: 24 }, chatIconSize: 56, zIndex: 1000, hideChatIcon: false, hideHistory: false, showBrowserNotifications: true, showTitleNotifications: true, }, function () { console.log('Chat is ready') }, ) ``` **Options** Most of the widget's look is configured in Kustomer, not in code: colors, greeting, team name and icon, business hours, offline behavior and which sites may embed it. See [Customization and security](https://developer.kustomer.com/docs/chat/web-customization). ## Control the widget Call these from the `start` callback or later. Before the SDK finishes loading they don't exist yet, and before `start` builds the widget most of them silently do nothing. Callbacks follow the `(response, error)` convention. | Method | What it does | | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `start(options, cb)` | Build the widget. See [Start options](#start-options). Ignored while the widget is already on the page, and `cb` doesn't run. | | `stop(cb)` | Remove the widget from the page. Listeners stay registered, and a later `start` builds the widget again. | | `open(cb)` / `close(cb)` | Open or close the chat window. | | `createConversation(params, cb)` | Open chat with a new conversation. See below. | | `createConversationInDraft({ message }, cb)` | Open a new conversation with `message` typed into the composer but not sent. | | `openConversationById({ conversationId }, cb)` | Open an existing conversation. | | `getOpenConversations(cb)` | The customer's conversations that haven't ended. Only after `start` finishes. | | `openKbArticle(articleId, cb)` | Open a knowledge base article in the widget. | | `openKbCategory(categoryId, cb)` | Open a knowledge base category. | | `isChatAvailable()` | Returns `{ availability }`: `online`, `offline`, `hidden` (off hours, set to hide chat) or `disabled`. | | `getUnreadCount()` | Returns `{ total, conversations }`, where `conversations` maps conversation IDs to counts. | | `login({ jwtToken }, cb)` | Identify the visitor as one of your users. See [Customer identity](https://developer.kustomer.com/docs/chat/identity). | | `isLoggedIn({ email } \| { externalId }, cb)` | Whether that user is logged in. `cb(true \| false)`. | | `logout(cb)` | End the session, clear the visitor's chat data from the browser, and reset the widget. | | `describeCustomer(params, cb)` | Set attributes on the current customer. See [Customer identity](https://developer.kustomer.com/docs/chat/identity#describe-the-customer). | | `describeConversation({ conversationId, customAttributes }, cb)` | Set custom attributes on a conversation. | | `setHistoryStatusFilter(filter)` | Change the history screen's status filter. | | `addListener(event, cb)` / `removeListener(event, cb)` | Subscribe to [widget events](#events). | ### Start a conversation from your page Open chat with a new conversation, for example from a **Help with this order** button: ```js title="Order page" document.querySelector('#order-help').addEventListener('click', function () { Kustomer.createConversation( { message: 'I need help with order #4417', customAttributes: { orderNumberStr: '4417' }, }, function (conversation, error) { if (error) return console.error(error) console.log('Created', conversation.conversationId) }, ) }) ``` **createConversation parameters** The callback receives the conversation: `conversationId`, `createdAt`, `ended` and, when it starts with an assistant, `isInAssistantMode`. ## Events ```js function onUnread({ total, change }) { document.querySelector('#chat-badge').textContent = total || '' console.log(`${change.count} unread in ${change.conversationId}`) } Kustomer.addListener('onUnread', onUnread) // later Kustomer.removeListener('onUnread', onUnread) ``` | Event | Fires when | | ---------------------------------- | ---------------------------------------------------------------------------------- | | `onOpen` | The chat window opens | | `onClose` | The chat window closes | | `onUnread` | The unread count changes. Receives `{ total, change: { conversationId, count } }`. | | `onConversationCreate` | The customer starts a conversation | | `onSessionOpened` | A conversation is opened in the widget. Receives `{ conversation }`. | | `onSessionCustomAttributesUpdated` | A conversation's custom attributes change | | `onLogin` | `login` succeeds | | `onLogout` | `logout` finishes | | `onUrlPreviewReceived` | A link preview loads for a message | Unknown event names are rejected with a console error. The Core API has a longer event list, including every message received; see [Web Core API › Events](https://developer.kustomer.com/docs/chat/web-core-api#events). ## Troubleshooting | Symptom | Fix | | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Console: `Kustomer API Key … appears to be invalid.` | The `data-kustomer-api-key` value isn't a chat key. Copy the whole key, with no quotes or line breaks. | | `Kustomer is not defined` | You called the SDK before `kustomerLoaded`. Wrap calls in the event listener. | | `Kustomer.open is not a function`, or a method does nothing | The SDK or the widget isn't ready yet. Call it from the `start` callback or later. | | `start` callback never runs | The widget was already started. Call `stop()` before starting it again. | | `getOpenConversations can only be run after Kustomer.start() completes` | Call it inside the `start` callback. | | Launcher never appears | Check chat is enabled for the brand, the page URL matches the brand's page rules, and `isChatAvailable()` isn't `hidden` or `disabled`. | | Blocked by Content Security Policy | Add the CDN to your policy, or pass a `nonce`. See [Customization and security › Content Security Policy](https://developer.kustomer.com/docs/chat/web-customization#content-security-policy). | | Widget sits under your header or cookie banner | Raise `zIndex`, or lower your banner's. | > **For agents adding chat to a site:** Add the two script tags once, in the root layout. Read the chat key from the environment, not from source. Then confirm in the browser console that `typeof Kustomer.open === 'function'` once the `start` callback has run. --- # Web Core API > Build a custom chat UI on KustomerCore, the headless web chat SDK. Loading core.js, init, conversations, messages, typing, read state, knowledge base, CSAT, identity, and every event. Source: https://developer.kustomer.com/docs/chat/web-core-api `core.js` is the chat SDK without a UI. It handles identity, conversations, messages, realtime delivery and the knowledge base, and you render everything yourself. The widget is built on the same Core, so anything the widget does, your UI can do too. > **Widget or Core?:** Use the [widget](https://developer.kustomer.com/docs/chat/web) unless you need a chat UI that's part of your own design system, such as chat embedded in a page rather than floating, or a fully custom message renderer. With Core you own accessibility, attachments, offline states and everything else the widget gives you for free. ## Load Core ```html title="index.html" ``` Use a chat key with only the `org.tracking` role, as for the widget. Core runs in a hidden iframe, defines `window.KustomerCore`, and fires `kustomerCoreLoaded` when it's ready. `init` calls made before that are queued. Every method takes an optional callback, called as `callback(response, error)`. Check `error` first: it's an `Error` with optional `status`, `code`, `title` and `source` from the API. ## init **KustomerCore.init(options, callback)** The response's `settings` is what admins configured for the brand in Kustomer. Use it to render your UI: | Setting | Meaning | | -------------------------------------------------------- | ------------------------------------------------------------ | | `teamName`, `teamIconUrl` | Who the customer is talking to | | `greeting`, `waitMessage` | Text for the empty state and while waiting for an agent | | `colors` | The brand's chat colors | | `widgetType` | Whether the brand uses chat, the knowledge base, or both | | `noHistory`, `singleSessionChat` | Hide past conversations, or allow only one open conversation | | `disableAttachments`, `closableChat` | Whether customers can attach files or end conversations | | `offhoursDisplay`, `offhoursMessage`, `offhoursImageUrl` | What to show outside business hours | | `activeAssistant`, `knowledgeBaseId` | The brand's assistant and knowledge base | | `domainCriteria` | Which page URLs should show chat. Check with `isUrlAllowed`. | Call `getChatSettings()` later to read them again, and `isChatAvailable()` for the current availability: `{ availability: 'online' | 'offline' | 'hidden' | 'disabled' }`. ## Build a minimal chat ```js title="custom-chat.js" let conversationId KustomerCore.addListener('onMessageReceived', function (message) { if (message.conversationId !== conversationId) return render(message) KustomerCore.markRead({ conversationId }) }) KustomerCore.addListener('onAgentTypingActivity', function (activity) { showTyping(activity) }) function startChat() { KustomerCore.createConversation({ title: 'Help with my order' }, function (conversation, error) { if (error) return showError(error) conversationId = conversation.conversationId }) } function send(body) { KustomerCore.sendMessage({ conversationId, body }, function (message, error) { if (error) return showError(error) render(message) }) } let typingTimer function onInput() { KustomerCore.sendTypingActivity({ conversationId, typing: true }) clearTimeout(typingTimer) typingTimer = setTimeout(function () { KustomerCore.sendTypingActivity({ conversationId, typing: false }) }, 3000) } ``` A message object looks like this: ```json title="Message" { "messageId": "6716a3b2c4e5f60012ab34cd", "conversationId": "6716a3a9c4e5f60012ab34c1", "body": "Your order shipped this morning.", "direction": "out", "createdAt": "2026-10-21T15:04:12.511Z", "attachments": [], "sentBy": { "type": "user", "id": "5f1b…", "displayName": "Ada", "avatarUrl": "https://…" }, "isInAssistantMode": false } ``` `direction` is `in` for the customer's messages and `out` for agents, assistants and automations. ## Methods ### Conversations | Method | Parameters | Response | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `createConversation(params, cb)` | `title` (required), `brand`, `assistant`, `lang`, `custom` | The conversation | | `getConversations(params, cb)` | `page`, `pageSize`, `brandId` | `{ conversations, pages }` | | `getConversation({ conversationId }, cb)` | | The conversation | | `endConversation({ conversationId }, cb)` | | Ends it. Fires `onConversationEnded`. | | `describeConversation({ conversationId, customAttributes }, cb)` | | `{ conversationId, custom }` | | `handoff(params, cb)` | `active`, `from`, `messages` (at least one `in` and one `out`), `brand`, `externalId`, `lang` | `{ conversationId, ended, createdAt }`. Imports a conversation that started elsewhere, such as in your own bot. | A conversation has `conversationId`, `createdAt`, `lastMessageAt`, `preview`, `lastResponder` (`displayName`, `avatarUrl`), `ended`, `isInAssistantMode` and `custom`. ### Messages | Method | Parameters | Response | | ------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `sendMessage(params, cb)` | `conversationId`, `body`, `attachments` (`[{ file: File }]`), `payload`, `lang` | The sent message | | `getMessages({ conversationId }, cb)` | | `{ conversationId, messages, satisfaction }` | | `getHistoricalMessages(params, cb)` | `conversationId`, `count`, `before` | Older messages, for scrolling back | | `markRead({ conversationId })` | | Marks every unread message in the conversation read, including CSAT | | `getUnreadCount()` | | `{ total, conversations }` (returned, not a callback) | | `sendTypingActivity({ conversationId, typing })` | | Shows or hides the customer's typing indicator to agents | | `sendPresenceActivity({ presence })` | `'online'` or `'offline'` | Tells agents whether the customer is on the page | Attachments are browser `File` objects, for example from ``. Respect `settings.disableAttachments`. ### Identity | Method | Parameters | Response | | --------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- | | `login({ jwtToken }, cb)` | A JWT signed by your server | `{ identified, customerId, email, externalId }` | | `isLoggedIn({ email } \| { externalId }, cb)` | | `true` or `false` | | `logout(cb)` | | Clears the session from the browser | | `describeCustomer(params, cb)` | `attributes` (`sharedEmails`, `sharedPhones`, `sharedSocials`), `customAttributes` | `{ customerId, custom, sharedEmails, … }` | See [Customer identity](https://developer.kustomer.com/docs/chat/identity) for generating the JWT. ### Knowledge base | Method | Parameters | Response | | ------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------- | | `getKnowledgeBaseConfig(cb)` | | The brand's knowledge base settings | | `getCategories(params, cb)` | `lang`, `knowledgeBaseId` | `{ rootCategory, categories }` | | `getArticles(params, cb)` | `categoryId`, `lang`, `knowledgeBaseId` | `{ articles }` | | `getFeaturedArticles(params, cb)` | `lang`, `knowledgeBaseId` | Featured articles | | `getArticleById(params, cb)` | `articleId`, `lang`, `knowledgeBaseId` | The article | | `getArticleBySlug(params, cb)` | `articleSlug`, `articleHash`, `lang`, `knowledgeBaseId` | The article | | `searchArticles(params, cb)` | `term`, `lang`, `knowledgeBaseId` | Matching articles | | `submitArticleFeedback(params, cb)` / `updateArticleFeedback(params, cb)` | Rating and written feedback | The feedback | Articles have `articleId`, `title`, `description`, `htmlBody`, `slug`, `lang`, `updatedAt` and `kbUrl`. `htmlBody` is HTML from your knowledge base: sanitize it before inserting it into your page. ### Satisfaction (CSAT) | Method | Parameters | | ------------------------------------------- | ------------------------------------------------------------ | | `getTranslatedSatisfactionForm(params, cb)` | The CSAT form in the customer's language | | `submitSatisfactionForm(params, cb)` | `satisfactionId`, `rating`, `answers`, `submittedAt`, `lang` | When Kustomer sends a CSAT survey for a chat conversation, Core fires `onSatisfactionReceived` with it. `getMessages` also returns it as `satisfaction`. ### Other | Method | Purpose | | ----------------------- | -------------------------------------------------------------------------------- | | `isUrlAllowed({ url })` | `{ allowed }`: whether chat should show on that URL under the brand's page rules | | `getChatSettings()` | The settings from `init` | | `isChatAvailable()` | Current availability | | `stop()` | Shut Core down | ## Events Subscribe with `KustomerCore.addListener(event, callback)` and unsubscribe with `removeListener(event, callback)`, passing the same function. | Event | Fires when | | ---------------------------------- | ------------------------------------------------------------------------- | | `onConversationCreate` | A conversation is created | | `onMessageReceived` | A message arrives from an agent, assistant or automation | | `onMessageSent` | The customer's message is sent | | `onUnreadCountChange` | Unread counts change | | `onAgentTypingActivity` | An agent starts or stops typing | | `onConversationEnded` | A conversation ends | | `onConversationUnended` | An ended conversation is reopened | | `onAssistantEnded` | The assistant finishes and the conversation moves on, usually to an agent | | `onSatisfactionReceived` | A CSAT survey arrives | | `onConversationMerged` | Two conversations are merged in Kustomer | | `onConversationDeleted` | A conversation is deleted in Kustomer | | `onCustomerDelete` | The customer is deleted in Kustomer | | `onSessionTitleChanged` | A conversation's title changes | | `onSessionCustomAttributesUpdated` | A conversation's custom attributes change | | `onSessionOpened` | A conversation is opened | | `onUrlPreviewReceived` | A link preview is ready for a message | | `onLogin` / `onLogout` | Identity changes | > **For agents building on Core:** Treat `onMessageReceived` as the source of truth for new messages and `getMessages` as the source for history. Key messages by `messageId` so a message delivered by both paths renders once. --- # Customization and security > What to configure in Kustomer versus in code, custom launchers, language and right-to-left support, domain restrictions, Content Security Policy with nonces, and privacy considerations for web chat. Source: https://developer.kustomer.com/docs/chat/web-customization Web chat is configured in two places. Admins set the brand's look and behavior in Kustomer, and every SDK (web, iOS, Android) picks it up. Your code controls where and how the widget sits on your page. ## Configured in Kustomer These come from the brand's chat settings and apply to every platform at once, with no deploy: | Setting | Effect | | ------------------------------------ | -------------------------------------------------------------------------------------- | | Colors, team name and icon | The widget's theme and header | | Greeting and wait message | Text before and after the customer's first message | | Business hours and off-hours display | Whether chat shows as online, offline (leave a message), or is hidden outside hours | | Assistant | The chatbot that answers first | | Knowledge base | Whether the widget shows chat, the knowledge base, or both | | Conversation history | Whether customers see past conversations, and whether they can have more than one open | | Attachments | Whether customers can send files | | Ending conversations | Whether customers can end a conversation themselves | | Page rules | Which page URLs show chat | The Core API returns all of these from [`init`](https://developer.kustomer.com/docs/chat/web-core-api#init), so a custom UI can honor them. ## Configured in code | Need | Option or method | | ---------------------------------------- | ---------------------------------------------------------------------------------- | | Move the launcher | `chatIconPosition: 'left'`, or `{ alignment, horizontalPadding, verticalPadding }` | | Resize the launcher | `chatIconSize: 64` | | Your own launcher | `hideChatIcon: true` plus `Kustomer.open()` | | Open on the knowledge base | `preferredView: 'kb'` | | Force a language | `lang: 'de'` | | Fix stacking | `zIndex` | | Different brand per section of your site | `brandId` | See [Start options](https://developer.kustomer.com/docs/chat/web#start-options) for the full list. ### Use your own launcher Hide Kustomer's button and drive the widget from yours, with an unread badge: ```html title="Custom launcher" ``` If chat can be `hidden` outside business hours, hide your button too: check `Kustomer.isChatAvailable().availability` when the page loads. ## Language The widget follows the visitor's browser language unless you set `lang`. It ships translations for more than 50 languages, including `en_us`, `es`, `fr`, `de`, `it`, `pt`, `pt_br`, `nl`, `sv`, `da`, `no`, `fi`, `pl`, `cs`, `tr`, `ru`, `uk`, `ja`, `ko`, `zh_cn`, `zh_tw`, `th`, `vi`, `id`, `hi`, `ar` and `he`. - Codes are matched case-insensitively, and `pt-BR` is treated as `pt_br`. - A regional code without its own translation falls back to the base language, so `fr_ca` uses `fr`. - `ar` and `he` switch the widget to right-to-left layout. Text you control in Kustomer (greeting, assistant dialogs, knowledge base articles) is translated separately in Kustomer. ## Restrict where chat runs Admins can limit chat to pages whose URL matches rules in the brand's chat settings. The widget hides itself on pages that don't match, and checks again whenever the URL changes, so it works with client-side routing: you can load the script on every page and let the rules decide. Headless UIs apply the same rules with `KustomerCore.isUrlAllowed({ url: location.href })`, which returns `{ allowed }`. These rules control where chat is shown. They aren't an access control: the chat key itself still works anywhere, which is safe because of what the `org.tracking` role allows. ## Content Security Policy The widget runs in iframes created from your page, so your page's Content Security Policy applies to it. ### Use a nonce If your policy uses nonces, put the nonce on the script tag: ```html ``` The SDK reads the nonce from the tag with `data-kustomer-api-key` and adds it to the scripts it loads, but only to inline scripts and scripts from Kustomer's CDN (`https://cdn.kustomerapp.com`). Scripts from any other origin never get your nonce. The same works for `core.js`. ### Directives Chat needs to load scripts from the CDN, call Kustomer's API, hold a realtime connection, and show images and attachments. Start from this policy, which is known to work, in `Content-Security-Policy-Report-Only` mode, then tighten `connect-src`, `img-src` and `media-src` using the violations your browser reports: ```text script-src 'nonce-r4nd0m-per-request'; style-src * 'unsafe-inline'; img-src * data: blob: https:; media-src *; connect-src *; object-src 'none'; ``` `style-src 'unsafe-inline'` is required: the widget sets inline styles for positioning and theming. ## Privacy - **Chat keys are public.** Anyone can read them from your page. That's expected: the `org.tracking` role can only act as the chat customer on that browser. Never put any other kind of API key in a web page. - **Anonymous by default.** Until you call [`login`](https://developer.kustomer.com/docs/chat/identity), Kustomer only knows the browser. Don't use `describeCustomer` to attach an email or phone you haven't verified; use `login` with a signed token instead. - **Log out on shared devices.** Call `Kustomer.logout()` when your user signs out, so the next person on the device doesn't see their conversations. - **Browser storage.** The SDK stores the session in the browser so conversations survive reloads. `logout()` clears it. --- # iOS > Add Kustomer Chat to an iOS app with the KustomerChat Swift SDK. Swift Package Manager and CocoaPods setup, Kustomer.setup, presenting the chat UI, the headless ChatProvider and KUSChatListener, identity, theming, localization, and the privacy manifest. Source: https://developer.kustomer.com/docs/chat/ios The iOS SDK is a single Swift module, `KustomerChat`. It contains both the prebuilt chat and knowledge base UI and the headless API underneath it, so every integration starts with `import KustomerChat`. | | | | ------------ | ---------------------------------------------------- | | SDK | 8.3.0 | | iOS | 15.0+ | | Swift | Swift 6 toolchain | | Distribution | Swift Package Manager, CocoaPods, or the XCFramework | Sample apps for SPM and CocoaPods: [`kustomer/ios-chat-sdk-samples`](https://github.com/kustomer/ios-chat-sdk-samples). ## Install **Swift Package Manager** In Xcode, choose **File › Add Package Dependencies…** and enter: ```text https://github.com/kustomer/kustomer-ios-spm ``` Use **Up to Next Major Version** from `8.3.0` and add the `kustomer-ios` product to your app target. Or in `Package.swift`: ```swift title="Package.swift" dependencies: [ .package(url: "https://github.com/kustomer/kustomer-ios-spm", from: "8.3.0"), ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "kustomer-ios", package: "kustomer-ios-spm"), ]), ] ``` The package is a prebuilt binary with no package dependencies to resolve. **CocoaPods** ```ruby title="Podfile" platform :ios, '15.0' use_frameworks! target 'MyApp' do pod 'KustomerChat', '~> 8.3.0' end ``` ```bash pod install ``` **XCFramework** Download `KustomerChat.xcframework` from [`kustomer/kustomer-ios`](https://github.com/kustomer/kustomer-ios), drag it into your project, and set it to **Embed & Sign**. It contains device and simulator slices. > **Upgrading to 8.3:** 8.3.0 requires JWTDecode 4.0 when you install with CocoaPods, so pins on JWTDecode 3.x no longer resolve. It also drops SnapKit; if your app used SnapKit only because the SDK brought it in, add it yourself. ### Info.plist Add usage descriptions so customers can send photos and videos: | Key | Why | | -------------------------------- | ------------------------------------------ | | `NSCameraUsageDescription` | Taking photos or videos to attach | | `NSMicrophoneUsageDescription` | Recording video with sound | | `NSPhotoLibraryUsageDescription` | Recommended for attaching from the library | Files are picked with the system document picker, which needs no permission. ## Set up #### Get a chat key Create an API key in **Settings › Security › API Keys** with **only** the `org.tracking` role and no expiration. Setup fails with any other roles. #### Call setup at launch ```swift title="AppDelegate.swift" import UIKit import KustomerChat @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let options = KustomerOptions() options.brandId = "YOUR_BRAND_ID" options.logLevels = [.error, .warning] _ = Kustomer.setup(apiKey: kustomerChatKey, options: options, launchOptions: launchOptions) { result in if case .failure(let error) = result { print("Kustomer setup failed: \(error)") } } return true } } ``` - Call it from `application(_:didFinishLaunchingWithOptions:)`. In a SwiftUI app, use `@UIApplicationDelegateAdaptor`. - `setup` returns the client, and the result isn't discardable, so assign it to `_`. - Call it once. A second call fails with `alreadyConfigured`, and options can't be replaced after setup. #### Open chat ```swift Kustomer.show() ``` `Kustomer` is a global, so every call is `Kustomer.method(…)`. `Kustomer.configure(…)` still works but is deprecated: `setup` always calls its completion. Check progress with `Kustomer.status`, which goes from `uninitialized` to `configured`, `started` and `connected`. ## Options `KustomerOptions` is a class; set properties before passing it to `setup`. | Property | Default | Description | | ----------------------------------------------- | --------------- | ----------------------------------------------------------------------------------- | | `brandId` | Org default | Brand whose chat settings and knowledge base to use | | `businessScheduleId` | Org default | Business schedule used for availability | | `language` | Device language | Force a `KustomerLanguage`, such as `.fr` or `.zh_CN` | | `activeAssistant` | `.orgDefault` | `.none`, `.orgDefault` or `.withId("…")`. UI only. | | `enableUI` | `true` | `false` for a headless integration. Also turns off in-app notifications. | | `showInAppNotifications` | `true` | Banners for new messages while your app is in the foreground | | `hideNewConversationButton` | `false` | Never show **New conversation** | | `hideNewConversationButtonInClosedChat` | `false` | Hide it only in ended conversations | | `hideHistoryNavigation` | `false` | Hide the back button to chat history | | `hideKbArticleShare` | `false` | Hide sharing on knowledge base articles | | `showAllBrandsConversationHistory` | `false` | Show every brand's conversations in history | | `enableHistoryStatusFilter` | `false` | Status filter menu on history: All, Open, Closed, Unread | | `historyStatusFilter` | `.none` | Initial filter: `.none`, `.open`, `.closed` or `.requiresAttention` | | `enableUrlUnfurling` | `true` | Link previews in messages | | `showAnimationFor5StarCSATRatings` | `false` | Celebrate 5-star satisfaction ratings | | `maxChatAttachmentTotalMB` | 50 | Total attachment size per message, 0–50 | | `messageAttachmentLocalCacheMaximumSizeInBytes` | 25 MB | Attachment cache size. Minimum 10 MB. | | `disablePush` | `false` | Don't register for push. A device that's already registered keeps receiving pushes. | | `pushEnvironment` | `.production` | `.development` for debug builds | | `pushBundleId` | Your bundle ID | Bundle ID to register push under | | `logLevels` | `[]` (off) | Any of `.debug`, `.info`, `.warning`, `.error`; `KustomerLogTypeAll` for everything | | `onShow`, `onDismiss` | `nil` | Closures called when the chat UI appears and goes away | Change some settings at runtime: ```swift Kustomer.overrideBrandId("OTHER_BRAND_ID") { error in } Kustomer.changeBusinessSchedule(scheduleId: "SCHEDULE_ID") { result in } Kustomer.changeActiveAssistant(.withId("ASSISTANT_ID")) Kustomer.setHistoryStatusFilter(.open) ``` ## Present the UI UI methods are safe to call from any thread; the SDK moves to the main thread itself. ```swift Kustomer.show() // follows the brand's widget setting Kustomer.show(preferredView: .onlyChat) Kustomer.show(preferredView: .knowledgeBase, presentingVC: self) Kustomer.startNewConversation( initialMessage: KUSInitialMessage(body: "Where is my order?", direction: .user), customDescribe: ["orderNumberStr": "4417"], afterCreateConversationOrError: { result in if case .success(let conversation) = result { print(conversation.id) } } ) Kustomer.openConversation(id: "6716a3a9c4e5f60012ab34c1") { result in } Kustomer.openChatAssistant(id: "ASSISTANT_ID", startDialog: nil, completion: { result in }) Kustomer.showKbArticle(id: "ARTICLE_ID") Kustomer.showKbCategory(id: "CATEGORY_ID") Kustomer.close(completion: nil) Kustomer.isVisible ``` | `KustomerDisplayMode` | Shows | | -------------------------------------- | --------------------------------------------------------- | | `.default` | The experience chosen in the brand's chat widget settings | | `.chatHistory` | Chat history | | `.newChat` | A new conversation | | `.activeChat` | The most recent active conversation | | `.knowledgeBase`, `.knowledgeBaseHome` | The knowledge base | | `.onlyChat` | Chat only, without the knowledge base tab | | `.onlyKnowledgeBase` | Knowledge base only, without the chat tab | - A `.user` initial message is sent and creates the conversation. A `.agent` one is shown as a greeting until the customer replies. - `customDescribe` sets conversation custom attributes, using attribute names with type suffixes. - In apps that use `UIScene`, pass `presentingVC:` so the SDK presents from the right window. - `openChatAssistant` and `close` have parameters without defaults, so `startDialog:` and `completion:` are required, as above. ### Unread counts and availability ```swift Kustomer.getUnreadCount { result in if case .success(let count) = result { badge.text = count > 0 ? "\(count)" : nil } } if Kustomer.isChatAvailable() == false { // chat is off, or the business schedule says it's closed } ``` `isChatAvailable()` returns `nil` before chat settings have loaded, `false` when chat is off or outside business hours, and `true` otherwise. `getOpenConversationCount` counts conversations that aren't ended and had a message in the last 30 days. ## Headless: ChatProvider For your own UI, set `options.enableUI = false` and use `Kustomer.chatProvider`: ```swift let chat = Kustomer.chatProvider chat.createConversation(firstCustomerMessage: "Where is my order?", title: "Order 4417") { result in switch result { case .success(let created): chat.sendChatMessage(text: "It was due Monday.", conversationId: created.conversation.id) { _ in } case .failure(let error): print(error) } } ``` | Method | Purpose | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | `getConversationPage(page:pageSize:completion:)` | The customer's conversations, paged. Prefer it over `getConversations`, which loads everything. | | `getConversation(conversationId:completion:)` | One conversation | | `getActiveConversations(completion:)` | Conversations that haven't ended | | `createConversation(firstCustomerMessage:title:customDescribe:…:completion:)` | Create a conversation. Returns the conversation, its messages and the customer. | | `endConversation(conversationId:completion:)` | End it | | `getChatMessages(conversationId:completion:)` | Messages, oldest first | | `getChatMessagesBefore(timestamp:conversationId:count:completion:)` | Older messages, **newest first** | | `sendChatMessage(text:conversationId:completion:)` | Send text. Overloads send an image (`image:`), a file (`fileUrl:`) or a button action (`action:`). | | `markSessionAsRead(sessionId:completion:)` | Mark a conversation read | | `listenForTyping(conversationId:)` / `stopListeningForTyping(conversationId:)` | Agent typing indicators | | `sendTypingStatus(for:isTyping:)` | The customer's typing indicator | | `sendPresenceActivity(_:)` | `.online` or `.offline` | | `describeConversation(conversationId:attributes:_:)` | Conversation custom attributes | | `reloadChatSettings(_:)` | Refresh chat settings. Don't call it on every launch. | With `enableUI = false`, start and stop the connection yourself with `Kustomer.start()` and `Kustomer.stop()`. Never call `stop()` when you use the SDK's UI. The knowledge base is on `Kustomer.kbProvider`: `getRootCategory(lang:knowledgeBaseID:completion:)`, `getCategory(by:childrenLimit:lang:knowledgeBaseID:completion:)`, `searchArticles(term:page:pageSize:lang:completion:)` and `getArticle(by:lang:knowledgeBaseID:completion:)`. Satisfaction surveys are handled by the UI only. ### Listen for events Conform to `KUSChatListener`. Every method has a default implementation, so implement only the ones you need, and keep the returned handle to remove the listener later. ```swift final class ChatEvents: KUSChatListener { func onChatMessageReceived(conversationId: String, chatMessage: KUSChatMessage) { } func onUnreadCountChange(count: Int) { } func onUserIsTyping(conversationId: String, user: KUSUser, timetoken: KUSPNTtoken?, ai: Bool) { } func onUserStopsTyping(conversationId: String, user: KUSUser, timetoken: KUSPNTtoken?) { } func onConversationEnded(conversationId: String, conversation: KUSConversation) { } } let events = ChatEvents() let handle = Kustomer.chatProvider.addChatListener(events) // later Kustomer.chatProvider.removeChatListener(handle) ``` > **Match signatures exactly:** Because every listener method has a default, a method with the wrong signature compiles but is never called. The typing callback includes `ai: Bool`; older docs left it out. | Callback | Fires when | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `onChatMessageReceived(conversationId:chatMessage:)` | A message arrives. Order isn't guaranteed; sort by `createdAt`. | | `onUnreadCountChange(count:)`, `onUnreadCountChange(conversationId:count:)` | Unread counts change. Called on the main thread. | | `onOpenConversationCountChange(count:)` | The open conversation count changes | | `onUserJoined(conversationId:user:)` | An agent joins | | `onUserIsTyping(…ai:)`, `onUserStopsTyping(…)` | Agent or AI typing starts and stops | | `onPreviewChanged(conversationId:preview:previewDetails:)` | The conversation preview text changes | | `onConversationCreated`, `onConversationEnded`, `onConversationReOpened`, `onConversationDeleted` | Conversation lifecycle, each with `(conversationId:conversation:)` | | `onConversationMerged(sourceConversation:targetConversation:)` | Two conversations are merged | | `onAssistantEnded(conversation:)` | An assistant finishes | | `onSatisfactionEventReceived(conversationId:satisfaction:)` | A satisfaction survey is sent | | `onCustomerDeleted()` | The customer was deleted | | `onSessionOpened`, `onSessionTitleChanged`, `onSessionCustomAttributesUpdated` | Conversation opened, renamed or updated | | `onUrlPreviewReceived(conversationId:message:urlUnfurled:)` | A link preview is ready | Connection state is on `ConnectionProvider.shared`: `status` is `connecting`, `connected`, `reconnecting`, `disconnected` or `disconnectedUnexpectedly`, and a `KUSConnectionListener` gets `onPubNubConnectionStatusChange(status:)`. ## Identity ```swift if !Kustomer.isLoggedIn(userEmail: nil, userId: user.id) { Kustomer.logIn(jwt: tokenFromServer) { result in switch result { case .success(let identity): print("Chat customer", identity.customerId) case .failure(let error): print(error) } } } Kustomer.chatProvider.describeCurrentCustomer(sharedPhones: ["+15555550123"], custom: ["loyaltyTierStr": "gold"]) // on sign-out Kustomer.logOut { error in } ``` - The identity is stored in the keychain, so check `isLoggedIn` instead of logging in on every launch. It compares `userId` with the token's `externalId` (case-insensitive), or `userEmail` with its `email`. - A successful login clears the local conversation cache and rebuilds the UI. The completion runs on the main thread, and calling `Kustomer.show()` inside it is safe. - `logOut` clears local state and deregisters push. It reports an error only if push deregistration failed, for example offline; local state is still cleared. - `logOutThenLogIn(jwt:_:)` switches users in one call. - In `describeCurrentCustomer`, use the `shared*` parameters; `email`, `phone`, `emails` and `phones` are deprecated. The JWT comes from your server. See [Customer identity](https://developer.kustomer.com/docs/chat/identity). ## Push notifications `setup` registers for remote notifications. Forward the device token from your `AppDelegate`: ```swift func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Kustomer.didRegisterForRemoteNotifications(deviceToken: deviceToken) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { Kustomer.didFailToRegisterForRemoteNotifications(error: error) } ``` APNs keys, permission prompts and notification delegates are covered in [Push notifications](https://developer.kustomer.com/docs/chat/push-notifications#ios). ## Theming Colors are customized by name, in either of two ways: - **Asset catalog.** Add a color set named `KustomerColor.` to your app's asset catalog, with Any and Dark appearances. - **Code.** Assign `KustomerUI.Color.` before calling `setup`. ```swift KustomerUI.Color.outgoingMessageBubble = UIColor(named: "Brand")! KustomerUI.Color.chatHeaderBackground = .systemBackground ``` | Area | Color names | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Messages | `incomingMessageBubble`, `incomingMessageText`, `outgoingMessageBubble`, `outgoingMessageText`, `messagesBackground`, `messageUrlLinkOnAgentBubble`, `messageUrlLinkOnCustomerBubble` | | Chat screen | `chatHeaderBackground`, `chatHeaderTitle`, `chatHeaderText`, `chatInputBarBackground`, `chatTextInputBackground`, `sendChatMessageButton`, `chatNewConversationButton`, `chatNewConversationButtonText` | | History | `historyBackground`, `historyHeaderBackground`, `historyRowBackground`, `historyRowTitle`, `historyRowSubtitle`, `historyBadgeBackground`, `historyBadgeText`, `historyNewChatButton` | | Knowledge base | `kbHeader`, `kbHeaderText`, `kbBrowseBackground`, `kbBrowseRowTitle`, `kbBrowseRowSubtitle`, `kbBrowseIconTint` | | Navigation | `navigationBarButton`, `tabBarBackground`, `tabBarItemColor`, `tabBarUnselectedItemColor` | | Buttons | `quickActionButton`, `quickActionButtonText`, `quickActionButtonBorder`, `kobjectButton`, `kobjectButtonText` | | Satisfaction | `csatText`, `csatCommentBoxBackground`, `csatCommentBoxOutline` | The built-in colors support dark mode. Text uses Dynamic Type; there's no font API. On iOS 26 and later the UI uses the system's Liquid Glass styling, unless your app sets `UIDesignRequiresCompatibility` in `Info.plist`. ## Localization The SDK follows the device language; set `options.language` to force one. To change any string, add a **localized** `Kustomer.strings` file (for example in `en.lproj`) with the keys you want to override: ```text title="en.lproj/Kustomer.strings" "com.kustomer.week" = "week"; ``` Run `Kustomer.printLocalizationKeys()` once to print every key. Strings are looked up in your localized `Kustomer.strings` first, then the SDK's own translation. A `Kustomer.strings` that isn't localized loses to the SDK's translations. ## Privacy The SDK includes a privacy manifest (`PrivacyInfo.xcprivacy`). It declares required-reason APIs for disk space, file timestamps, user defaults and system boot time, declares no collected data types, and doesn't track. Xcode merges it into your app's privacy report. ## Troubleshooting | Symptom | Fix | | --------------------------------------------------------------- | ------------------------------------------- | | Setup fails: "API key must contain only the org.tracking role" | Use a key with only `org.tracking`. | | `Result of call to 'setup' is unused` warning | Write `_ = Kustomer.setup(…)`. | | `alreadyConfigured` | `setup` ran twice. Call it once, at launch. | | Typing indicator callback never fires | Your method is missing `ai: Bool`. | | Chat opens behind other UI, or not at all, in a scene-based app | Pass `presentingVC:`. | | String overrides ignored | Localize `Kustomer.strings`. | | `Kustomer.close()` doesn't compile | Use `Kustomer.close(completion: nil)`. | --- # Android > Add Kustomer Chat to an Android app with the Kotlin SDK. Gradle setup, Kustomer.init, opening the chat UI, the headless KustomerCore API and KusChatListener, identity, attachments, theming, localization, and R8. Source: https://developer.kustomer.com/docs/chat/android The Android SDK is two Maven artifacts. `ui` gives you the full chat and knowledge base experience as an Activity; `core` is the headless layer it's built on. Depending on `ui` brings in `core`. | Artifact | Package | Use it to | | ------------------------ | ------------------- | ----------------------------------------------------------- | | `com.kustomer.chat:ui` | `com.kustomer.ui` | Open Kustomer's chat UI from your app. Most apps want this. | | `com.kustomer.chat:core` | `com.kustomer.core` | Build your own UI on the chat API. | Sample apps: [`kustomer/android-chat-sdk-samples`](https://github.com/kustomer/android-chat-sdk-samples). ## Requirements | | Version | | ----------------------------- | --------------------------------- | | SDK | 4.6.2 | | minSdk | 24 | | compileSdk / targetSdk | 35 | | Java | 17 | | Kotlin (your app's toolchain) | **2.2 or later**, since SDK 4.6.0 | ## Install #### Add the dependency The artifacts are on Maven Central: ```kotlin title="settings.gradle.kts" dependencyResolutionManagement { repositories { google() mavenCentral() } } ``` ```kotlin title="app/build.gradle.kts" dependencies { implementation("com.kustomer.chat:ui:4.6.2") } ``` #### Enable desugaring on minSdk 24 and 25 Only apps with a minSdk below 26 need this: ```kotlin title="app/build.gradle.kts" android { compileOptions { isCoreLibraryDesugaringEnabled = true } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") } ``` #### Get a chat key Create an API key in **Settings › Security › API Keys** with **only** the `org.tracking` role and no expiry. The SDK rejects keys with any other role. #### Initialize in your Application class ```kotlin title="MyApplication.kt" class MyApplication : Application() { override fun onCreate() { super.onCreate() val options = KustomerOptions.Builder() .setBrandId("YOUR_BRAND_ID") .setLogLevel(KusLogLevel.Error) .build() try { Kustomer.init(application = this, apiKey = BuildConfig.KUSTOMER_CHAT_KEY, options = options) { result -> Log.i("Kustomer", "Initialized: ${result.dataOrNull}") } } catch (e: AssertionError) { Log.e("Kustomer", "Invalid chat key", e) } } } ``` Initialize in `Application.onCreate()`, not in an Activity, so the SDK is ready when Android restarts your process. An invalid key throws `AssertionError`. Calling `Kustomer.getInstance()` before `init` throws `KusNotInitializedException`. #### Open chat ```kotlin Kustomer.getInstance().open() ``` > **Add listeners after init:** `Kustomer.init` removes every chat listener before it registers its own. Call `addChatListener` after `init`, and again if you ever call `init` a second time. ## Options `KustomerOptions.Builder()` methods all return the builder: **KustomerOptions.Builder** You can change some of these at runtime on `Kustomer.getInstance()`: `overrideBrand(brandId)`, `overrideBusinessSchedule(scheduleId)`, `changeActiveAssistant(activeAssistant)`, `setHistoryStatusFilter(filter)`. ## Open the UI All of these are on `Kustomer.getInstance()`. Callbacks run on the main thread. ```kotlin val kustomer = Kustomer.getInstance() kustomer.open() // follows the brand's widget setting kustomer.open(KusPreferredView.CHAT_ONLY) kustomer.open(KusPreferredView.KB_ONLY) kustomer.startNewConversation( initialMessage = KusInitialMessage("Where is my order?", KusChatMessageDirection.CUSTOMER), describeAttributes = KusDescribeAttributes(hashMapOf("orderNumberStr" to "4417")), title = "Order 4417", ) { result -> if (result is KusResult.Success) Log.i("Kustomer", "Conversation ${result.data.id}") } kustomer.openConversationWithId("6716a3a9c4e5f60012ab34c1") { } kustomer.openChatAssistant(assistantId = "YOUR_ASSISTANT_ID") kustomer.openKbArticle("ARTICLE_ID") { } kustomer.openKbCategory("CATEGORY_ID") { } kustomer.close() // closes the UI; doesn't log out kustomer.isVisible() ``` | `KusPreferredView` | Opens | | --------------------- | -------------------------------------------------- | | `DEFAULT` | Whatever the brand's widget setting says | | `CHAT_HISTORY` | Chat and knowledge base, on the chat tab | | `KNOWLEDGE_BASE_HOME` | Chat and knowledge base, on the knowledge base tab | | `CHAT_ONLY` | Chat only | | `KB_ONLY` | Knowledge base only | About `startNewConversation`: - A `CUSTOMER` initial message is sent right away and creates the conversation. An `AGENT` one is shown as a greeting, and the conversation is created when the customer replies. Assistants ignore `AGENT` initial messages. - `title` is capped at 256 characters. Without one, the first message is used. - `KusDescribeAttributes` sets custom attributes on the new conversation, using attribute names such as `orderNumberStr`. `openConversationWithId` follows merges: if the conversation was merged, it opens the one it was merged into. The chat UI runs in its own portrait Activity. ## Unread counts and availability ```kotlin Kustomer.getInstance().observeUnreadCount().observe(this) { count -> badge.isVisible = count > 0 badge.text = count.toString() } Kustomer.getInstance().isChatAvailable { result -> val availability = result.dataOrNull // KUS_ONLINE, KUS_OFFLINE, KUS_HIDDEN or KUS_DISABLED helpButton.isVisible = availability != KusChatAvailability.KUS_HIDDEN && availability != KusChatAvailability.KUS_DISABLED } ``` `observeUnreadCount()` counts conversations updated in the last 30 days. `observeActiveConversationIds()` gives the IDs of open conversations. ## Headless: KustomerCore To build your own UI, use the providers on `KustomerCore`. With the `ui` artifact, call `Kustomer.init` (it initializes Core for you); with only `core`, call `KustomerCore.init(application, apiKey, options)`. ```kotlin val chat = KustomerCore.getInstance().kusChatProvider() lifecycleScope.launch { when (val created = chat.createConversation(title = "Order 4417", messages = listOf("Where is my order?"), attachments = null)) { is KusResult.Success -> { val (conversation, messages) = created.data chat.sendMessageWithAttachments(body = "It was due Monday.", messageAction = null, attachments = null, conversationId = conversation.id) } is KusResult.Error -> Log.e("Kustomer", "Create failed", created.exception) KusResult.Loading -> Unit } } ``` Provider methods are `suspend` functions that return `KusResult`: `Success(data)` or `Error(exception)`. `KusResult` also has a `Loading` object, so a `when` expression needs a branch for it. | Method | Purpose | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `createConversation(title, messages, attachments, …, attributes, assistantId)` | Always creates a new conversation. Returns the conversation and its first messages. | | `fetchConversations(page, pageSize)` | The customer's conversations. Only open ones when the brand hides history. | | `getConversation(id)` / `fetchConversation(id)` | One conversation, from cache or forcing the network | | `endConversation(id)` | End it | | `sendMessageWithAttachments(body, messageAction, attachments, conversationId)` | Send a message, with optional `KusUploadAttachment`s | | `fetchChatMessages(conversationId, page, pageSize)` | Messages and CSAT items, mixed in one list | | `getChatMessagesBeforeTimestamp(conversationId, timeStamp, pageSize)` | Scroll back through history | | `markSessionAsRead(conversationId)` | Mark everything read. Call it when the customer leaves the conversation. | | `sendTypingStatus(conversationId, KusTypingStatus.CUST_TYPING_STARTED)` | The customer's typing indicator. End it with `CUST_TYPING_ENDED`. | | `subscribeToTypingStatus(channel)` / `unsubscribeFromTypingStatus(channel)` | Agent typing, using the `SHARED_CONVERSATION` channel from `conversation.channelInfo` | | `describeConversation(id, customAttributes)` | Custom attributes on a conversation | | `submitSatisfactionForm(satisfactionId, response, conversationId)` | Submit CSAT | | `isChatAvailable()` / `getChatSettings()` | Availability and the brand's chat settings | The knowledge base is on `KustomerCore.getInstance().kusKbProvider()`: `fetchRootCategory(locale)`, `fetchFeaturedArticles(locale)`, `fetchCategoryById(id, locale)`, `searchArticles(term, page, pageSize, locale)` and `getArticleById(id, locale, knowledgeBaseId)`. ### Listen for events Implement `KusChatListener` and register it after `init`: ```kotlin val listener = object : KusChatListener { override fun onChatMessageReceived(conversationId: String, chatMessage: KusChatMessage) { } override fun onAgentIsTyping(conversationId: String, typingIndicator: KusTypingIndicator) { } override fun onUnreadCountChange(conversationId: String, unreadCount: Int) { } override fun onAgentJoined(conversationId: String, agent: KusUser) { } override fun onPreviewChanged(conversationId: String, preview: KusConversationPreview) { } override fun onConversationLastMessageAtChanged(conversationId: String, lastMessageAt: Long) { } override fun onConversationCreated(conversation: KusConversation) { } override fun onConversationEnded(conversation: KusConversation) { } override fun onConversationDeleted(conversation: KusConversation) { } override fun onConversationUnended(conversation: KusConversation) { } override fun onCustomerMerged(customerId: String) { } override fun onCustomerDeleted() { } override fun onSatisfactionEventReceived(conversationId: String, satisfaction: KusSatisfaction) { } override fun onAssistantEnded(conversation: KusConversation) { } override fun onConversationMerged(source: KusConversation, target: KusConversation) { } } Kustomer.getInstance().addChatListener(listener) ``` `onChatMessageReceived` fires for the customer's own messages too; check `chatMessage.direction`. `onAgentJoined` fires on the first agent reply, not on assignment. `onSessionOpened`, `onSessionTitleChanged`, `onSessionCustomAttributesUpdated` and `onUrlPreviewReceived` have default implementations, so override them only if you need them. ## Identity ```kotlin val kustomer = Kustomer.getInstance() if (kustomer.isLoggedIn(userEmail = user.email).dataOrNull != true) { kustomer.logIn(jwtFromYourServer) { result -> if (result is KusResult.Error) Log.e("Kustomer", "Login failed", result.exception) } } kustomer.describeCustomer( KusCustomerDescribeAttributes(sharedPhones = listOf(KusPhone("+15555550123")), custom = mapOf("loyaltyTierStr" to "gold")) ) { } // on sign-out lifecycleScope.launch { kustomer.deregisterDeviceForPushNotifications() kustomer.logOut() } ``` - `isLoggedIn(userEmail, userId)` is synchronous and compares case-insensitively with the identity in the stored token. - Call `logIn` after your user signs in, not on every launch. - `logOut()` clears the SDK's data but doesn't deregister push, so deregister first. The JWT comes from your server. See [Customer identity](https://developer.kustomer.com/docs/chat/identity). ## Attachments | Limit | Value | | ------------- | ----------------------------------------------------------------------------------- | | Per file | 5 MB, after image compression | | Per message | 9.5 MB in the UI | | Message text | 10,240 characters | | Blocked types | Executables and scripts, such as `exe`, `bat`, `msi`, `ps1`, `vbs`, `scr` and `lnk` | Images are re-encoded as JPEG and downscaled to about one megapixel before upload. Admins can turn attachments off in chat settings. The SDK registers a `FileProvider` with the authority `${applicationId}.kustomersdk`, so don't use that authority yourself. It doesn't declare the `CAMERA` permission: photos use the system camera app through `ACTION_IMAGE_CAPTURE`. ## Theming The chat Activity uses the theme `KusAppTheme.Overlay`, based on `Theme.MaterialComponents.DayNight`. Override it in your app to apply your colors: ```xml title="res/values/styles.xml" ``` | Attribute | Colors | | --------------------------------------------------------------------------------------- | ------------------------- | | `kusColorPrimary`, `kusColorOnPrimary` | Primary actions | | `kusColorSecondary`, `kusColorOnSecondary` | Selected navigation items | | `kusColorHeader`, `kusColorOnHeader`, `kusColorOnHeaderVariant` | The app bar | | `kusColorAgentBubble`, `kusColorOnAgentBubble`, `kusColorUrlLinkOnAgentBubble` | Agent messages | | `kusColorCustomerBubble`, `kusColorOnCustomerBubble`, `kusColorUrlLinkOnCustomerBubble` | Customer messages | | `kusColorBackground`, `kusColorSurface`, `kusColorOnSurface`, `kusColorSurfaceVariant` | Backgrounds and cards | | `kusColorError`, `kusColorListDivider` | Errors and dividers | The SDK follows your app's night mode. Put dark values in `res/values-night/styles.xml` too. Text styles (`KusTextAppearance.*`) can be overridden the same way. ## Localization The SDK includes translations for more than 100 locales and follows the device language. Force one with `setUserLocale`, or follow per-app languages with `useAppLocales(true)`. Every UI string is a `kus_*` resource you can override in your own `strings.xml`. ## R8 and ProGuard Rules ship inside the AARs and apply automatically, including in R8 full mode. ## Troubleshooting | Symptom | Fix | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `AssertionError: Kustomer API key contains invalid roles` | Use a key with only `org.tracking`. | | Build fails reading Kotlin metadata | Upgrade your Kotlin toolchain to 2.2 or later. | | `KusNotInitializedException` | `Kustomer.init` hasn't run. Call it from `Application.onCreate()`. | | Listener never fires | You added it before `init`. Add it after. | | `Manifest merger failed` on a FileProvider | Your provider uses `${applicationId}.kustomersdk`. Rename yours. | | No notifications on Android 13+ | Declare `POST_NOTIFICATIONS` and request it. See [Push notifications](https://developer.kustomer.com/docs/chat/push-notifications#android). | --- # React Native > Add Kustomer Chat to a React Native app with @kustomer/chat-react-native. Installation for iOS and Android, configure, opening the chat UI, events and hooks, identity, push, and the platform differences to know about. Source: https://developer.kustomer.com/docs/chat/react-native `@kustomer/chat-react-native` wraps the native [iOS](https://developer.kustomer.com/docs/chat/ios) and [Android](https://developer.kustomer.com/docs/chat/android) SDKs, so your app gets the native chat UI on each platform from one JavaScript API. | | Version | | --------------------- | ------------------------------------------------------------ | | Package | `@kustomer/chat-react-native` 6.3.6 | | Native SDKs inside | iOS 8.3.0, Android 4.6.2 | | Built and tested with | React Native 0.77, React 18, New Architecture with Hermes | | iOS | 15.0+ (15.1+ on React Native 0.77) | | Android | minSdk 24, compileSdk 35, Kotlin 2.2+ | | Expo | Not in Expo Go. Use a development build with native changes. | > **Use the native UI:** The React Native package is built around the prebuilt chat UI. Theming and custom headless UIs need the native SDKs: customize colors natively, as described on the [iOS](https://developer.kustomer.com/docs/chat/ios#theming) and [Android](https://developer.kustomer.com/docs/chat/android#theming) pages. ## Install #### Add the package ```bash npm install @kustomer/chat-react-native ``` For push notifications, also install `@react-native-firebase/app` and `@react-native-firebase/messaging`. #### iOS Set the platform in your `Podfile` and install pods: ```ruby title="ios/Podfile" platform :ios, '15.1' ``` ```bash cd ios && pod install ``` Add usage descriptions for attachments to `Info.plist`: `NSCameraUsageDescription`, `NSPhotoLibraryUsageDescription`, `NSPhotoLibraryAddUsageDescription` and `NSMicrophoneUsageDescription`. #### Android The Android SDK needs Kotlin 2.2 or later and, on minSdk 24 and 25, core library desugaring: ```groovy title="android/build.gradle" buildscript { ext { kotlinVersion = "2.2.21" } } ``` ```groovy title="android/app/build.gradle" android { compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") } ``` The build stops with a clear error if `kotlinVersion` is older than 2.2.0 or isn't a full `MAJOR.MINOR.PATCH` version. #### Configure ```tsx title="App.tsx" import { useEffect } from 'react' import KustomerChat from '@kustomer/chat-react-native' export default function App() { useEffect(() => { KustomerChat.configure(KUSTOMER_CHAT_KEY, { brandId: 'YOUR_BRAND_ID' }).catch((e) => console.error('Kustomer configure failed', e), ) }, []) return } ``` Use a chat key with only the `org.tracking` role. Call `configure` once, early. `useIsKustomerConfigured()` returns `true` once it's done. #### Open chat ```tsx