# API reference

> The SDK API, the dashboard API, WebSockets, payloads and errors.

Source: https://conciergesdk.com/docs/api/

You rarely call the API directly; the SDKs and the dashboard do. This reference is for building your own client, debugging, or pointing an agent at it.

JSON uses camelCase. Timestamps are ISO 8601 strings in UTC.

| Environment | Base URL |
|---|---|
| Local dev | `http://localhost:4000` (iOS simulator) or `http://10.0.2.2:4000` (Android emulator) |
| Hosted | `https://api.conciergesdk.com` |

## IDs and keys

IDs are prefixed and URL safe: `org_`, `app_`, `cus_` (end customer), `dev_` (device), `cnv_`, `msg_`, `mem_`, `kdoc_`. They're generated on the server, except a message's `clientId`, which the SDK generates (a UUID) for idempotency.

- `app_...` is the **public app key**. It's safe to embed in apps.
- `ids_...` is the **identity secret**. Server only. It signs user JWTs (HS256).
- `dtk_...` is a **device token**, returned when a device registers.

## SDK API

Base path `/v1/sdk`. Every call sends `X-App-Key: app_...`. After registering, every call also sends `Authorization: Bearer <deviceToken>`.

| Method | Path | Body or query | Returns |
|---|---|---|---|
| GET | `/v1/sdk/config` | App key only | `{ appearance, ai: { enabled, showAsAi }, identity: { requireVerified } }`, cached for 300s |
| POST | `/v1/sdk/devices` | `{ platform, osVersion, appVersion, sdkVersion, model, locale }` | `{ deviceId, deviceToken, customerId }` |
| POST | `/v1/sdk/identify` | `{ userId, name?, email?, token?, metadata? }` | `{ customerId, verified }`. Without a valid token the claim is scoped to this device |
| POST | `/v1/sdk/logout` | | `{ customerId }`, a new anonymous customer |
| GET | `/v1/sdk/conversation` | | `{ conversation, messages, hasMore }`, the latest 50 |
| GET | `/v1/sdk/messages` | `?before=<seq>&limit=50` or `?after=<seq>` | `{ messages, hasMore }` |
| POST | `/v1/sdk/messages` | `{ clientId, body, attachments? }` | `Message`. `201` when created, `200` on a retry with the same `clientId` |
| POST | `/v1/sdk/read` | `{ seq }` | `{ ok: true }` |
| POST | `/v1/sdk/typing` | | `{ ok: true }` |
| GET | `/v1/sdk/unread` | | `{ count }` |
| POST | `/v1/sdk/push-token` | `{ token, environment }` | `{ ok: true }` |
| POST | `/v1/sdk/uploads` | multipart `file` (JPEG, PNG or HEIC, up to 10MB) | `{ url, width, height, mime }` |
| POST | `/v1/sdk/context` | `{ metadata?, breadcrumbs?: [{ name, at }] }` | `{ ok: true }` |

Notes:

- `POST /v1/sdk/identify` without a token answers `401 identity_verification_required` when **Require verified users** is on (the default for new apps), or when that `userId` was ever verified.
- `POST /v1/sdk/messages` answers `403 identity_verification_required` when **Require verified users** is on and the customer isn't verified (signed-out devices are allowed while **Signed-out chat** is on).
- The conversation is created on first fetch, so it's never null. The inbox only lists conversations with at least one message.
- Upload types are sniffed from the bytes, not trusted from the multipart header.
- Customer metadata is string to string, up to 50 keys. The last 50 breadcrumbs are kept.

### SDK WebSocket

`GET /v1/sdk/ws?appKey=app_...&token=<deviceToken>` (or send the same credentials as headers). Open it only while the chat is visible. Reconnect with backoff and catch up with `GET /v1/sdk/messages?after=<lastSeq>`.

Server to client:

```json
{ "type": "message.created", "message": { } }
{ "type": "typing", "authorType": "team" }
{ "type": "read", "seq": 12 }
{ "type": "conversation.updated", "conversation": { } }
```

Client to server: `{ "type": "ping" }` every 25s (answered with `pong`) and `{ "type": "typing" }`.

Close codes: a bad key or token is rejected with HTTP `401` on the upgrade. `4401` after the upgrade means auth was lost (for example, the app was deleted), so register again instead of reconnecting with the same token. `1001` (server restart) and `4000` (heartbeat timeout) mean reconnect with backoff.

### Message

```json
{
  "id": "msg_x",
  "conversationId": "cnv_x",
  "seq": 12,
  "clientId": "uuid",
  "authorType": "team",
  "author": { "name": "Kirill", "avatarUrl": null },
  "body": "hi",
  "attachments": [],
  "card": null,
  "createdAt": "2026-10-23T10:00:00Z"
}
```

`authorType` is `customer`, `team`, `ai` or `system`. `attachments` are `[{ url, width, height, mime }]`.

Cards:

```json
{ "type": "review", "title": "Enjoying the app?", "buttonLabel": "Rate us", "url": "itms-apps://..." }
{ "type": "offer", "title": "Yearly is 40% off this week", "buttonLabel": "View offer", "paywallId": "yearly_40" }
```

## Dashboard API

Base path `/v1`. Authenticated with the dashboard session cookie. Calls act in your active organization, or in the one named by an `X-Org-Id` header.

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/me` | `{ user, orgs, activeOrgId }` |
| GET, POST | `/v1/apps` | Create with `{ name, iconUrl?, appStoreUrl? }` |
| GET, PATCH, DELETE | `/v1/apps/:appId` | PATCH accepts `name`, `iconUrl`, `appStoreId`, `playPackage` (review cards link there; `null` unlinks) and `settings` (deep merge) |
| GET | `/v1/apps/:appId/setup` | `{ connected, firstMessageAt, setupPrompt, appKey, cardHandlers, storeListing }`. `cardHandlers` is `{ paywall, offer }` from the latest device on SDK 0.1.1 or later, or `null` |
| POST | `/v1/apps/:appId/identity-secret/rotate` | `{ identitySecret, previousIdentitySecret, previousIdentitySecretExpiresAt }`. The old secret keeps verifying for 24 hours |
| GET | `/v1/apps/:appId/conversations` | `?status=open\|snoozed\|closed\|all&q=&cursor=&limit=` returns `{ items, nextCursor }` |
| GET | `/v1/conversations/:id` | `{ conversation, customer, devices, messages, hasMore }` |
| GET | `/v1/conversations/:id/messages` | `?before=&after=&limit=` |
| POST | `/v1/conversations/:id/messages` | `{ clientId, body, card? }`. A team reply pauses the AI on that conversation |
| PATCH | `/v1/conversations/:id` | `{ status?, assigneeId?, priority?, aiState? }` |
| POST | `/v1/conversations/:id/read` | Marks the team as read up to the latest message |
| GET | `/v1/apps/:appId/customers` | `?q=&cursor=&limit=` |
| GET | `/v1/customers/:id` | Customer, devices and conversation summary |
| GET, POST | `/v1/apps/:appId/knowledge` | List, or create `{ title, content }` |
| PATCH, DELETE | `/v1/knowledge/:id` | |
| GET | `/v1/apps/:appId/stats` | `{ open, closed, medianFirstResponseSec, aiResolved }` |

Conversation `status` is `open`, `snoozed` or `closed`. `priority` is `normal`, `high` or `urgent`. `aiState` is `active`, `paused` or `off`.

### App settings

`PATCH /v1/apps/:appId` with a partial `settings` object. It's deep merged.

```json
{
  "appearance": { "accentLight": "#6B4CF2", "accentDark": "#9E85FF", "title": "Support", "welcome": "Hi! How can we help?", "showTeamNames": true, "showTeamPhotos": false },
  "ai": { "enabled": false, "model": "claude-haiku-5-5", "showAsAi": true, "handoff": "on_request", "systemPrompt": "" },
  "identity": { "requireVerified": true, "allowAnonymous": true },
  "push": { "apnsConfigured": false, "fcmConfigured": false }
}
```

### Privacy: export and delete

For GDPR and CCPA requests from your end users (you are the controller, Concierge is your processor under the [DPA](/legal/dpa/)). Owner or admin only. Besides the dashboard session, these accept a staff API key (`Authorization: Bearer ck_...`), so you can automate requests from your backend.

| Method | Path | What it does |
|---|---|---|
| GET | `/v1/customers/:id/export` | Everything stored for one customer as JSON: profile, devices, conversations, messages, attachment URLs, cards, offers, purchases, consents, refund requests and more |
| DELETE | `/v1/customers/:id` | Hard deletes all of it in one transaction, including uploaded photos, and returns counts per table |
| GET | `/v1/apps/:appId/users/:userId/export` | The same, by your own user id (every customer of the app with that verified or claimed id) |
| DELETE | `/v1/apps/:appId/users/:userId` | Deletes every customer of the app matching your user id |

For your own dashboard account: `GET /v1/account/export`, `DELETE /v1/account` with `{ "confirm": "<your email>" }` (refused with `409 sole_owner` while you are the only owner of an organization), and `DELETE /v1/org` with `{ "confirm": "<org name>" }` (owner only; cancels the subscription, then deletes every app and all data). These take the session cookie only. The same actions are on the Team page and on each customer's panel in the inbox.

### Import conversation history

`POST /v1/apps/:appId/conversations/import` brings past conversations from your previous support tool in as history. Owner or admin, Pro plan. Takes the dashboard session or a staff API key (`Authorization: Bearer ck_...`). The body is `{ "conversations": [...] }` in the shape the [switch guide](https://conciergesdk.com/docs/migrate.md#conversation-history) shows, at most 100 conversations and 2,000 messages per request (1 MB).

The response lists each conversation as `imported`, `skipped` (`already_imported` or `too_many_messages`) or `failed`, with the Concierge `conversationId` and `customerId`. Imported conversations never trigger AI replies, push or emails. `402 subscription_required` before you subscribe, `429 rate_limited` past 120 requests an hour or 50,000 conversations a day per workspace.

### Setup prefill

`POST /v1/apps/:appId/setup/prefill` saves what the setup prompt's coding agent found in your codebase, for agents without the [MCP server](https://conciergesdk.com/docs/mcp.md). Takes the dashboard session or a staff API key (`Authorization: Bearer ck_...`). Body: `{ storeListing?: { appStoreId?, playPackage? }, aiInstructions?, offers?: [...], knowledge?: [{ title, content }] }`. Nothing reaches customers until you act: store ids and the AI system prompt only fill empty values, offers are saved with the `draft` status and articles as drafts, and names that already exist are skipped, so it is safe to run again. The response lists what was created, kept or skipped, plus notes.

### Dashboard WebSocket

`GET /v1/ws` with the session cookie. Org scoped events: `message.created` (with `appId`), `conversation.updated`, `typing`, `customer.updated` and `app.connected` (the first message from a new app). Send `{ "type": "typing", "conversationId": "cnv_..." }` to show the team typing on the customer's device.

## Errors

Every error looks like this, with a matching HTTP status:

```json
{ "error": { "code": "invalid_request", "message": "body is required", "details": { } } }
```

| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | Validation failed. `details` has the field issues. |
| 401 | | Missing or bad app key, device token or session. |
| 403 | `identity_verification_required` | Verified identity is required and the customer isn't verified. |
| 404 | | Not found, or it belongs to another organization. |
| 413, 415 | | Upload too big, or not JPEG, PNG or HEIC. |
| 429 | `rate_limited` | Slow down. Honor `Retry-After`. |
