API Β· v1

API Reference

Users, bulk sync, history, schema, prompts, and webhooks.

Coding agents, refer to: llms.txt

🚧
Pre-release. Schemas, endpoints, and contracts may change.

Overview

TeamSquared is based on a flexible, spreadsheet-like data model. The API exposes the data schema (columns), views (sheets), and users (rows in the sheet, each one a user of AI), plus other system resources.

Bearer tokens are granted permission scopes and bound to a view.

Base URL

https://api.teamsquared.ai/api/v1

Version

Current: 1.0.0 (path: /v1). Breaking changes bump the path segment; non-breaking changes bump semver. See the changelog.

curl https://api.teamsquared.ai/api/v1/users \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl

Authentication

Bearer token in the Authorization header. Tokens are 64 chars prefixed ext_agent_.

Create a token

  1. Log in to app.teamsquared.ai.
  2. Open Settings β†’ API Tokens.
  3. Click New token, name it, pick the scopes and bound view, then create.
  4. Copy the full token immediately β€” see the warning below.
⚠️
Shown once. Store the full token immediately β€” only a prefix is shown afterward. ext_agent_REPLACE_ME below is a placeholder.
Authorization: Bearer ext_agent_REPLACE_ME
header

Scopes

Tokens carry one or more case-sensitive resource:permission scopes that gate which endpoints they can call. Scopes are picked in the UI when the token is created (Settings β†’ API Tokens β†’ New token) and can't be changed afterward β€” issue a new token to widen or narrow access. Missing scope β†’ 403.

ScopeGrantsv1 endpoints
users:readRead users and per-user history.GET /users, GET /users/:userId/history, GET /users/:userId/chat-messages
users:writeCreate/update users, bulk upsert, append history.POST /users, POST /users/batch, PUT /users/by-recipient/:recipientId, PATCH /users/:userId, POST /users/:userId/history
users:deleteHard-delete users. Not implied by users:write.DELETE /users/:userId
schema:readRead schema metadata.GET /user_schema
schema:writeCreate/update schema fields.POST /user_schema, PATCH /user_schema/:fieldName
prompts:readResolve active prompts.POST /prompts/resolve
prompts:writeReserved.No v1 endpoint yet.
webhooks:readList webhook subscriptions.GET /webhooks, GET /webhooks/:id
webhooks:writeCreate, update, delete, test subscriptions.POST /webhooks, PATCH /webhooks/:id, DELETE /webhooks/:id, POST /webhooks/:id/test
{
  "error": "Forbidden",
  "message": "Missing required scope: users:write"
}
403 response

Users

Each row in the spreadsheet, whether it's a lead, contact, customer, invoice, ticket, etc is (or represents) a unique user of the AI system. A continuous history record is stored for each user.

ℹ️
Terminology note: The term "user" mirrors the chat-message role in OpenAI, Anthropic, and other LLM APIs.

In TeamSquared, almost everything is a user. A "user" row can represent any of:

  • Live β€” a real production user of the AI system.
  • Sandbox β€” a synthetic user created for experimentation and prompt iteration.
  • Discussion β€” an example user that anchors a discussion, ticket, or escalation thread.
  • Eval β€” an example user used as a training or evaluation case.
  • Rated β€” a snapshot captured from a thumbs-up / thumbs-down rating.

This unified shape lets the platform freely clone, replay, and transport conversations between modes for AI improvement: a live conversation can be cloned into a sandbox to iterate on a fix, promoted into an eval to lock in expected behavior, or anchored into a discussion to track an escalation.

User type is exposed as userType on every record. Tokens (and webhooks) can opt into the user types they care about β€” see Events & user types.

Data model & views

Similar to a spreadsheet, the data model is flexible. Views (shown in the left sidebar of the dashboard below) define a filtered subset of users with limited data for a specific purpose. Every API token is bound to a view; the view determines which fields are returned on reads and which fields callers can filter or sort by.

TeamSquared dashboard showing Views in the left sidebar β€” All Wellness Programs, Weight Loss Program, and High Performers
"All Wellness Programs", "Weight Loss Program", and "High Performers" are three views in this Wellness Center workspace. Each is bindable to an API token to scope the fields and rows that token can read.

What reads return

  • Always returned β€” id, recipientId, externalId, createdAt, lastResponseAt, lastUpdatedAt, archivedAt, doNotContact.
  • View fields β€” the view's curated list.
  • Reserved fields β€” never returned, regardless of view.
⚠️
Not a write authorization boundary. A :write token can mutate any user/schema field under its agent β€” view filters do not gate writes. Enforce narrower auth in your integration if needed.
πŸ”’
Lock view management to superadmins. Since views control which fields a token can read, view edits are an exposure surface β€” restrict view CRUD to superadmins to prevent unauthorized field-exposure changes.
πŸ’‘
Need a different data model? Create a new token bound to a wider or narrower view.
curl "https://api.teamsquared.ai/api/v1/users?sortBy=wellness_score&sortOrder=desc" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl Β· list users in view

Assuming the Bearer token is bound to the view shown.

// View exposes: name, wellness_goal, program_type, wellness_score
{
  "users": [
    {
      "id": 103,
      "recipientId": "+15125550103",
      "externalId": "crm_88213",
      "createdAt": "2025-09-14T08:12:04.118Z",
      "lastResponseAt": "2026-04-29T17:05:22.901Z",
      "lastUpdatedAt": "2026-04-30T11:18:43.220Z",
      "archivedAt": null,
      "doNotContact": false,
      "name": "Juan HernΓ‘ndez",
      "wellness_goal": "nutrition",
      "program_type": "6-month",
      "wellness_score": 97
    },
    // ...7 more users
  ],
  "totalCount": 8,
  "pagination": {
    "limit": 50,
    "offset": 0,
    "hasMore": false,
    "nextCursor": null
  }
}
200 response

Detecting changes

lastUpdatedAt bumps on every write that changes the row. Three patterns:

  • Webhooks β€” subscribe to user.updated; each delivery names the changedFields and carries the projected user. See Webhooks.
  • Incremental sync β€” ?updatedSince=<iso>&sortBy=lastUpdatedAt&sortOrder=asc, follow pagination.nextCursor until null. Subtract 60 s from your watermark each run to catch in-flight writes.
  • Full audit β€” sortBy=id&sortOrder=asc&limit=200, follow nextCursor. Every user in the view appears exactly once.
ℹ️
Pushing data the other way? Bulk upsert skips unchanged rows, so a full re-send never bumps lastUpdatedAt or fires events.
Code example β€” ERP sync (Node, ~60 lines)

Mirrors users + chat history into a stand-in ERP. Initial offset-paged load, then a single ?updatedSince= catch-up. Read-only token.

const TOKEN = process.env.TOKEN;
const API = 'https://api.teamsquared.ai/api/v1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Stand-in for the ERP's own table. The ERP β€” not the API β€”
// owns the record of when it last saved each user.
const erp = {
  records: new Map(),
  save(u) {
    this.records.set(u.id, {
      recipientId: u.recipientId,
      name: u.name ?? null,
      savedLastUpdatedAt: u.lastUpdatedAt,
    });
  },
  newestSavedAt() {
    return [...this.records.values()]
      .map((r) => r.savedLastUpdatedAt).sort().at(-1);
  },
};

async function initialSync() {
  const limit = 20;
  let offset = 0;
  while (true) {
    const url = new URL(`${API}/users`);
    url.searchParams.set('limit', String(limit));
    url.searchParams.set('offset', String(offset));
    const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
    const { users, pagination } = await res.json();

    for (const u of users) {
      erp.save(u);
      const mres = await fetch(`${API}/users/${u.id}/chat-messages?limit=100`,
        { headers: { Authorization: `Bearer ${TOKEN}` } });
      const { messages } = await mres.json();
      console.log(JSON.stringify({ user: u, chatMessages: messages }, null, 2));
      await sleep(1000); // stay under 60 req/min
    }

    if (!pagination.hasMore) break;
    offset += limit;
    await sleep(1000);
  }
}

// In production, fired by a webhook subscription on user.updated:
// the ERP exposes an HTTP handler, the API POSTs the change, and
// the handler calls periodicSync(). Polling is a fallback.
async function periodicSync() {
  const since = erp.newestSavedAt();
  const url = new URL(`${API}/users`);
  url.searchParams.set('updatedSince', since);
  url.searchParams.set('limit', '200');
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  const { users } = await res.json();
  const changed = [];
  for (const u of users) {
    const existing = erp.records.get(u.id);
    if (existing && existing.savedLastUpdatedAt === u.lastUpdatedAt) continue;
    erp.save(u);
    changed.push(u);
  }
  console.log(JSON.stringify({ changed }, null, 2));
}

await initialSync();
await periodicSync();
sync-poc.mjs
curl --get "https://api.teamsquared.ai/api/v1/users" \
  --data-urlencode "updatedSince=2026-05-01T00:00:00Z" \
  --data-urlencode "sortBy=lastUpdatedAt" \
  --data-urlencode "sortOrder=asc" \
  --data-urlencode "limit=200" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
# Then: ...&cursor= until nextCursor is null.
curl Β· incremental sync

Rate limiting

Fixed-window, per-token, not per-IP. Two independent counters:

CounterDefaultApplies to
default60 req/minEvery endpoint except bulk upsert
batch20 req/minPOST /users/batch (up to 1,000 users each)

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). On exceed: 429 with Retry-After. Please honor it. Higher limits for a token are available on request.

HTTP/1.1 429 Too Many Requests
Retry-After: 47
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1788480047
Content-Type: application/json

{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded: 60 req/min"
}
429 response

Idempotency

Send an Idempotency-Key header (≀128 chars, unique per token) on POST /users, POST /users/batch, PUT /users/by-recipient/:recipientId and DELETE /users/:userId. A retry with the same key and body returns the stored response with Idempotent-Replayed: true instead of running again. Keys expire after 24 hours.

  • 409 idempotency_in_progress β€” the first request is still running.
  • 422 idempotency_key_reused β€” same key, different body.
  • Responses of 5xx are not stored; retry freely.
curl -X POST "https://api.teamsquared.ai/api/v1/users/batch" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Idempotency-Key: sync-2026-09-03T14:00Z-page-7" \
  -H "Content-Type: application/json" \
  -d @page-7.json
# Same key + same body again β†’ identical response, Idempotent-Replayed: true
curl

Errors

StatusMeaningTriggered by
200OKRead or update succeeded
201CreatedResource created
400Bad RequestMissing fields, bad params, filter/sort outside view
401UnauthorizedMissing or invalid token
403ForbiddenMissing scope
404Not FoundResource doesn't exist
409ConflictDuplicate recipientId (recipient_conflict) or externalId (external_id_conflict); idempotent request in progress
422UnprocessableIdempotency-Key reused with a different body
429Too Many RequestsRate limit β€” see Rate limiting
500Internal Server ErrorServer error

JSON body with error, often message and a machine-readable code, plus context (recipientId, fieldName, …).

{
  "error": "User not found",
  "recipientId": "+1234567890"
}
404 response
{
  "error": "Bad Request",
  "message": "Filter field not in view: selected_office"
}
400 response

Users

All under /api/v1/users. Single-user lookup: filter the list by recipientId, or pass ?externalId=. Updates and deletes address users by id; PUT /users/by-recipient/:recipientId addresses them by channel address.

ℹ️
recipientId is the primary channel address (phone, WhatsApp, etc.) β€” a cross-platform customer key. Unique per agent. Inbound webhooks resolve against it. externalId is your key for the same person β€” also unique per agent, never changed by TeamSquared.

List users

GET /api/v1/users

Paginated, view-scoped list with filters and sort. Single-user fetch: filter by recipientId.

Required scope users:read

ℹ️
Caller filters AND with the view's β€” narrow only. Filter/sort outside the view β†’ 400. This holds for cursor walks too: a "full" walk returns every user in the view, not every user under the agent.

Query parameters

ParameterTypeDescription
limitnumberDefault 50, max 200 (clamped).
offsetnumberDefault 0. Must be a multiple of limit. Not with cursor.
cursorstringpagination.nextCursor from the previous page. Only with sortBy of lastUpdatedAt, createdAt or id. Stable while rows change underneath.
sortBystringDefaults to view's sortBy, then lastUpdatedAt. Must be in the view.
sortOrderasc | descDefaults to view's, then desc.
filtersJSON Filter[]URL-encoded. AND'd on top of view filters. See operators.
logicalOperatorand | orComposes caller filters. Default and.
updatedSinceISO 8601Shorthand for an incremental sync filter β€” see Detecting changes.
includeCountbooleanAdds totalCount. Off by default β€” COUNT(*) is expensive.
externalIdstringLookup shorthand: returns the user with that externalId.
includeArchivedbooleanArchived users are hidden by default. true includes them; filtering on archivedAt also does.

Errors

  • 400 Filter field not in view: <field>
  • 400 offset must be a multiple of limit
  • 400 cursor_with_offset, cursor_unsupported_sort, invalid_cursor
curl --get "https://api.teamsquared.ai/api/v1/users" \
  --data-urlencode "limit=50" \
  --data-urlencode "offset=0" \
  --data-urlencode "includeCount=true" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl Β· basic
FILTERS='[{"field":"recipientId","operator":"eq","value":"+1234567890"}]'

curl --get "https://api.teamsquared.ai/api/v1/users" \
  --data-urlencode "filters=${FILTERS}" \
  --data-urlencode "limit=1" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
# Returns { "users": [user] } on hit, { "users": [] } on miss.
curl Β· single-user lookup
FILTERS='[{"field":"status","operator":"eq","value":"active"},
          {"field":"name","operator":"like","value":"Smith"}]'

curl --get "https://api.teamsquared.ai/api/v1/users" \
  --data-urlencode "filters=${FILTERS}" \
  --data-urlencode "limit=50" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl Β· with filters
{
  "users": [
    {
      "id": 137,
      "recipientId": "+1234567890",
      "externalId": "crm_123",
      "createdAt": "2025-10-31T02:37:58.753Z",
      "lastResponseAt": null,
      "lastUpdatedAt": "2026-05-01T10:42:11.005Z",
      "archivedAt": null,
      "doNotContact": false,
      "name": "John Doe",
      "appointment_time": "2026-05-02T10:00:00Z"
    }
  ],
  "totalCount": 137,
  "pagination": {
    "limit": 50,
    "offset": 0,
    "hasMore": true,
    "nextCursor": "eyJ2IjoxLCJzIjoibGFzdFVwZGF0ZWRBdCIsIm8iOiJkZXNjIiwiayI6WyIyMDI2LTA1LTAxVDEwOjQyOjExLjAwNTAwMCIsMTM3XX0"
  }
}
200 response

Create user

POST /api/v1/users

Create a user. Extra keys are stored as custom data.

Required scope users:write

Body

FieldTypeDescription
recipientIdreqstringPhone or unique channel address.
externalIdstringYour own id for this user, ≀256 chars. Unique per agent.
namestringDisplay name.
lastResponseAtISO 8601Seed the inferred last-response timestamp.
archivedAtISO 8601 | nullSet to archive. Archived users leave lists and receive no messages.
doNotContactbooleanSuppress every outbound message on every channel.
doNotContactReasonstringWrite-only; stored in history, never returned.
[any other key]anyCustom data. Must be in the bound view.

Errors

  • 400 recipientId missing
  • 409 recipient_conflict β€” user with that recipientId exists
  • 409 external_id_conflict β€” another user owns that externalId
curl -X POST "https://api.teamsquared.ai/api/v1/users" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientId": "+1234567890",
    "name": "John Doe",
    "selected_office": "Manhattan",
    "appointment_time": "2025-11-01T10:00:00Z"
  }'
curl
{
  "user": {
    "id": 137,
    "recipientId": "+1234567890",
    "createdAt": "2025-10-31T02:37:58.753Z",
    "lastResponseAt": null,
    "lastUpdatedAt": "2025-10-31T02:37:58.753Z",
    "name": "John Doe",
    "selected_office": "Manhattan",
    "appointment_time": "2025-11-01T10:00:00Z"
  }
}
201 response

Update user

PATCH /api/v1/users/:userId

Custom fields are merged β€” send only what changed.

Required scope users:write

ℹ️
lastUpdatedAt bumps on every write. Writes are agent-scoped β€” the bound view does not restrict which users can be updated.

Body (all optional)

FieldTypeDescription
namestringDisplay name.
lastResponseAtISO 8601Override the inferred last-response timestamp.
archivedAtISO 8601 | nullArchive, or null to restore.
doNotContactbooleanSet or clear suppression. Each change writes a dnc_set / dnc_cleared history entry and fires user.unsubscribed / user.resubscribed.
doNotContactReasonstringWrite-only; recorded on the history entry.
[any other key]anyMerged into custom data. Must be in the bound view.

Read the suppression audit trail with GET /users/:userId/history?event=dnc_set. STOP replies and the agent's opt-out tool set the same flag, so treat doNotContact as the source of truth for consent.

curl -X PATCH "https://api.teamsquared.ai/api/v1/users/137" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "appointment_confirmed": true
  }'
curl
{
  "user": {
    "id": 137,
    "recipientId": "+1234567890",
    "createdAt": "2025-10-31T02:37:58.753Z",
    "lastResponseAt": null,
    "lastUpdatedAt": "2025-10-31T02:42:11.005Z",
    "name": "John Doe",
    "appointment_time": "2025-11-01T10:00:00Z",
    "appointment_confirmed": true
  }
}
200 response

Bulk upsert

POST /api/v1/users/batch

Create or update up to 1,000 users in one call. Each item succeeds or fails on its own; matched rows with nothing new are left untouched β€” no write, no lastUpdatedAt bump, no webhook.

Required scope users:write

Body

FieldTypeDescription
usersreqobject[]1–1,000 items, same fields as Create user.
matchOnrecipientId | externalIdDefault recipientId. With externalId, recipientId is required only on items that create a user; a different recipientId on a match updates it.
webhooksemit | suppressDefault emit. Use suppress for the initial backfill.

Add ?include=users for the full user object on every successful item. Always 200; inspect summary and each item's status: created, updated (with changedFields), unchanged or error.

Item error codes

CodeMeaning
missing_keyThe matchOn key (or recipientId on a create) is absent.
duplicate_in_batchAn earlier item used the same recipientId or externalId.
field_not_writableField outside the token's view, or locked.
immutable_fieldid, agentId, workspaceId, createdAt.
recipient_conflictrecipientId belongs to a different user.
external_id_conflictexternalId belongs to a different user.
invalid_valueBad timestamp, non-boolean doNotContact, etc.
internalUnrelated failure β€” retry the item.

Errors

  • 400 batch_too_large β€” more than 1,000 items
  • 400 invalid_request β€” missing or empty users, unknown matchOn / webhooks
πŸ’‘
Mirroring an external user base. Backfill with webhooks: "suppress" in pages of 1,000 keyed by your externalId. Each cycle, send what changed on your side with matchOn: "externalId". Pull changes made in TeamSquared with updatedSince and skip webhook envelopes whose origin is your own token.
curl -X POST "https://api.teamsquared.ai/api/v1/users/batch" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "matchOn": "externalId",
    "webhooks": "emit",
    "users": [
      { "externalId": "crm_123", "recipientId": "ana@example.com", "name": "Ana" },
      { "externalId": "crm_124", "recipientId": "+15555550124", "name": "Ben",
        "doNotContact": true, "doNotContactReason": "asked to stop" }
    ]
  }'
curl
{
  "summary": { "received": 2, "created": 1, "updated": 0, "unchanged": 0, "failed": 1 },
  "results": [
    { "index": 0, "recipientId": "ana@example.com", "externalId": "crm_123",
      "id": 4021, "status": "created" },
    { "index": 1, "recipientId": "+15555550124", "externalId": "crm_124",
      "status": "error",
      "error": { "code": "field_not_writable",
                 "message": "Fields not writable through this token's view: plan" } }
  ]
}
200 response

Upsert by address

PUT /api/v1/users/by-recipient/:recipientId

The single-user form of Bulk upsert: body is one user item, the path wins over any recipientId in it, same matching and change detection.

Required scope users:write

ℹ️
URL-encode the address: + as %2B, @ as %40.
  • 201 { user, status: "created" }
  • 200 { user, status: "updated", changedFields } or { user, status: "unchanged" }
  • 400 / 409 with the item error codes above
curl -X PUT "https://api.teamsquared.ai/api/v1/users/by-recipient/ana%40example.com" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Ana", "externalId": "crm_123" }'
curl
{
  "user": {
    "id": 4021,
    "recipientId": "ana@example.com",
    "externalId": "crm_123",
    "createdAt": "2026-09-03T14:02:11.410Z",
    "lastResponseAt": null,
    "lastUpdatedAt": "2026-09-03T14:02:11.410Z",
    "archivedAt": null,
    "doNotContact": false,
    "name": "Ana"
  },
  "status": "created"
}
201 response

Delete user

DELETE /api/v1/users/:userId

Hard delete. Removes the user, its history, channel identities, comments and eval runs, and fires user.deleted. Prefer archivedAt if you only need the user hidden.

Required scope users:delete

  • 204 deleted, no body
  • 404 unknown id, or a user under another agent
  • 403 token lacks users:delete

Left behind: audit-log rows referencing the id, copies inside already-queued webhook payloads (aged out by retention), and request logs that captured the address.

curl -X DELETE "https://api.teamsquared.ai/api/v1/users/137" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
# 204 No Content
curl

History

Per-user event log β€” tool runs, state changes, messages. Read raw or as LLM-shaped chat messages.

Add history entry

POST /api/v1/users/:userId/history

Append an event entry.

Required scope users:write

Body

FieldTypeDescription
eventreqstringEvent type. Free-form for general events, e.g. appointment_scheduled. Conversation turns must use the reserved names below.
messagereqstringHuman-readable summary.

Naming conversation turns

Most event names are yours to choose, but the two that record a conversation are not. Use user chat for a turn from the person and agent chat for a turn from your agent. These are the names TeamSquared reads when it assembles a conversation, so they are what makes a turn show up in the chat panel, come back from Get chat messages, and count toward the message and conversation charts on the dashboard.

A channel suffix is fine β€” user chat message whatsapp and agent chat message email are both read as conversation turns. An event named anything else is stored and returned by List history, but it is treated as a general event rather than as part of the conversation.

ℹ️
user_message and assistant_message still work. Earlier versions of this page used those names. They remain accepted everywhere conversations are read, permanently β€” an integration written against the old guidance keeps working and needs no migration. New integrations should use user chat and agent chat.
curl -X POST \
  "https://api.teamsquared.ai/api/v1/users/137/history" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "user chat",
    "message": "Hello, I need help"
  }'
curl Β· conversation turn
curl -X POST \
  "https://api.teamsquared.ai/api/v1/users/137/history" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "appointment_scheduled",
    "message": "User scheduled appointment for Nov 1, 2025"
  }'
curl Β· general event
{
  "history": {
    "id": 1172,
    "userId": 137,
    "event": "appointment_scheduled",
    "message": "User scheduled appointment for Nov 1, 2025",
    "createdAt": "2025-10-31T02:35:50.315Z"
  }
}
201 response

List history

GET /api/v1/users/:userId/history

Paginated history with optional event and recency filters.

Required scope users:read

Query parameters

ParameterTypeDescription
limitnumberDefault 100.
offsetnumberDefault 0.
eventstringExact match.
sinceSecondsnumberLast N seconds (e.g. 86400 = 24h).
curl --get "https://api.teamsquared.ai/api/v1/users/137/history" \
  --data-urlencode "limit=50" \
  --data-urlencode "event=user chat" \
  --data-urlencode "sinceSeconds=86400" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "history": [
    {
      "id": 1172,
      "userId": 137,
      "event": "user chat",
      "message": "Hello, I need help",
      "createdAt": "2025-10-31T02:35:50.315Z"
    },
    {
      "id": 1173,
      "userId": 137,
      "event": "agent chat",
      "message": "Hi! How can I help you today?",
      "createdAt": "2025-10-31T02:35:51.420Z"
    }
  ],
  "totalCount": 3,
  "pagination": { "limit": 50, "offset": 0, "hasMore": false }
}
200 response

Get chat messages

GET /api/v1/users/:userId/chat-messages

History flattened to { role, content } β€” drop into a prompt.

Required scope users:read

  • user / assistant roles only.
  • content is always a string; complex content is JSON-stringified.
  • Only conversation turns appear here β€” history entries named user chat or agent chat, with or without a channel suffix. The legacy user_message and assistant_message names are accepted too, so conversations recorded by an older integration still come back.
  • General events such as appointment_scheduled are skipped. Fetch those from List history.
curl "https://api.teamsquared.ai/api/v1/users/137/chat-messages" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "messages": [
    { "role": "user", "content": "Hello, I need help" },
    { "role": "assistant", "content": "Hi! How can I help you today?" },
    { "role": "user", "content": "I want to schedule an appointment" },
    { "role": "assistant", "content": "Sure β€” what date works for you?" }
  ]
}
200 response

User schema

Built-in fields plus any custom fields you define. Custom fields are exposed via the bound view.

List schema fields

GET /api/v1/user_schema

View-exposed custom fields. UI/admin metadata (icons, render types) is not part of the API.

Required scope schema:read

curl "https://api.teamsquared.ai/api/v1/user_schema" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "fields": [
    { "fieldName": "name", "type": "string" },
    { "fieldName": "selected_office", "type": "string",
      "enum": ["Manhattan", "Brooklyn", "Queens"] },
    { "fieldName": "appointment_time", "type": "string",
      "format": "date-time" }
  ]
}
200 response

Create field

POST /api/v1/user_schema

Add a custom field. Built-in fields aren't creatable via the API.

Required scope schema:write

Body

FieldTypeDescription
fieldNamereqstringsnake_case identifier.
typereqenumstring Β· number Β· boolean
enumstring[]Allowed values.
formatstringe.g. date-time.

Errors

  • 409 Field already exists
curl -X POST "https://api.teamsquared.ai/api/v1/user_schema" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "fieldName": "insurance_provider",
    "type": "string"
  }'
curl
{
  "field": {
    "fieldName": "insurance_provider",
    "type": "string"
  }
}
201 response

Update field

PATCH /api/v1/user_schema/:fieldName

Update metadata. fieldName and type are immutable β€” to rename or retype, create a new field and migrate.

Required scope schema:write

curl -X PATCH \
  "https://api.teamsquared.ai/api/v1/user_schema/insurance_provider" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "enum": ["Blue Cross", "Aetna", "UnitedHealth", "Self-Pay"]
  }'
curl
{
  "field": {
    "fieldName": "insurance_provider",
    "type": "string",
    "enum": ["Blue Cross", "Aetna", "UnitedHealth", "Self-Pay"]
  }
}
200 response

Prompts

Versioned prompt strings keyed by promptType. Resolves to the agent's active version with optional {{placeholder}} interpolation.

Resolve prompt

POST /api/v1/prompts/resolve

Placeholders: {{fieldName}}, {{chatHistory}}, {{now}}.

Required scope prompts:read

Body

FieldTypeDescription
promptTypereqstringe.g. my-agent.
adminUserIdstringUser-specific selection during admin testing.
interpolatebooleanInline sub-prompts. Default false.
curl -X POST "https://api.teamsquared.ai/api/v1/prompts/resolve" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "promptType": "my-agent",
    "interpolate": true
  }'
curl
{
  "prompt": {
    "id": 42,
    "type": "my-agent",
    "content": "You are a helpful assistant...",
    "version": "v2.1",
    "isActive": true,
    "createdAt": "2025-10-15T10:00:00.000Z",
    "updatedAt": "2025-10-30T14:30:00.000Z"
  },
  "meta": {
    "promptVersions": [
      {
        "trackingField": "prompt_version_my_agent",
        "trackingValue": "v2.1"
      }
    ]
  }
}
200 response

Webhooks

Get notified in your own systems when something changes inside TeamSquared. Each subscription POSTs a signed JSON envelope to a URL of your choosing.

How it works

When a subscribed event fires, TeamSquared queues a delivery, signs it with HMAC-SHA256, and POSTs it to your URL. Failures retry with exponential backoff.

Subscriptions are managed in the dashboard (agent superadmin only) or through the API with the webhooks:* scopes β€” see Manage via API.

πŸ’‘
Pair with the API. user.created and user.updated carry the projected user and the changedFields, so most handlers can write straight to your system. Fetch via GET /users only when you need fields outside the subscription's view.
TeamSquared                         Your service
───────────                         ────────────
event fires (e.g. user.updated)
  β†’ enqueue outbound row
  β†’ cron processor (~1 min)
      β†’ POST {url}                  ──▢ verify X-Webhook-Signature
                                       process payload
                                       respond 2xx
  ← 2xx β€” mark sent
  ← non-2xx / timeout β€” backoff & retry
flow

Create a subscription

Settings β†’ Webhooks β†’ Create Webhook. Set:

  • Name β€” internal label.
  • Destination URL β€” HTTPS endpoint that will receive deliveries.
  • Events β€” one or more (see below).
  • User Types β€” which user types fire this webhook. Defaults to Live.

The signing secret (whsec_…) is shown once. Copy it immediately β€” it can't be retrieved later.

Row β‹― menu: Send test event, Pause / Resume, Delete.

Settings
└── Webhooks
    └── Create Webhook
        β”œβ”€β”€ Name:           CRM sync
        β”œβ”€β”€ Destination URL: https://hooks.example.com/teamsquared
        β”œβ”€β”€ Events:
        β”‚     β˜‘ User created
        β”‚     β˜‘ User updated
        β”‚     ☐ User deleted
        β”‚     ☐ Chat message created
        └── User Types:
              β˜‘ Live
              ☐ Sandbox
              ☐ Discussion
              ☐ Eval
              ☐ Rated
create flow

Events & user types

Fires only when event matches and the user's type is in the subscription's User Types list.

EventFires when
user.createdA new user is added.
user.updatedAny public field changes β€” custom fields included, from the API, agents, tools or the dashboard. Never fires for lastUpdatedAt alone.
user.deletedA user is deleted.
user.unsubscribeddoNotContact flips to true (API, STOP reply, opt-out tool, dashboard). Payload adds reason, source, occurredAt.
user.resubscribeddoNotContact flips back to false.
chat.message.createdA chat message is recorded β€” inbound (user) or outbound (agent).
Example: a subscription on user.updated
with User Types = [live, sandbox] will fire
when a sandbox user is edited, but a
"live"-only subscription will not.
filter rules

Payload

POST, Content-Type: application/json, three signing headers:

HeaderValue
X-Webhook-EventEvent type, e.g. user.updated.
X-Webhook-IdUnique delivery id β€” use for at-least-once dedupe.
X-Webhook-Signaturesha256=<hex> β€” HMAC-SHA256 of the raw body using your secret.

For user.created / user.updated, data adds changedFields (empty on create) and user β€” the user projected through the subscription's view, exactly as GET /users would return it to a token on that view. A subscription with no view gets the always-returned fields plus the changed values. Other events keep the identifying fields only.

When the change came through the API the envelope carries origin ({ kind, tokenId, tokenPrefix }), so a mirror can skip events its own writes produced.

{
  "eventType": "user.updated",
  "occurredAt": "2026-05-04T17:30:00.123Z",
  "webhookId": 42,
  "agentId": 1,
  "workspaceId": 1,
  "origin": { "kind": "api", "tokenId": 12, "tokenPrefix": "ext_agent_ab12" },
  "data": {
    "id": 137,
    "recipientId": "+1234567890",
    "externalId": "crm_123",
    "userType": "live",
    "doNotContact": false,
    "archivedAt": null,
    "createdAt": "2025-10-31T02:37:58.753Z",
    "lastUpdatedAt": "2026-05-04T17:30:00.005Z",
    "changedFields": ["name", "appointment_time"],
    "user": {
      "id": 137,
      "recipientId": "+1234567890",
      "externalId": "crm_123",
      "createdAt": "2025-10-31T02:37:58.753Z",
      "lastResponseAt": null,
      "lastUpdatedAt": "2026-05-04T17:30:00.005Z",
      "archivedAt": null,
      "doNotContact": false,
      "name": "John Doe",
      "appointment_time": "2026-05-09T10:00:00Z"
    }
  }
}
envelope

Receiving & verifying

Drop-in Node.js / Express handler. Verifies the signature in constant time, then processes the event.

  • Use express.raw() β€” JSON-parsing changes byte order and breaks the signature.
  • Compare with timingSafeEqual to avoid timing attacks.
  • Return 2xx within 10 s. Defer slow work.
import express from 'express';
import crypto from 'node:crypto';

const app = express();

app.post(
  '/webhooks/teamsquared',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verifySignature(req.body, req.header('x-webhook-signature'))) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // event.eventType, event.data.id
    // req.header('x-webhook-id') β€” dedupe key

    res.status(200).end();   // ack fast; defer work
  }
);

function verifySignature(rawBody, header) {
  if (!header) return false;
  const expected = 'sha256=' +
    crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
          .update(rawBody)
          .digest('hex');
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
handler.js

Retries & testing

Any 2xx acknowledges (body ignored). Anything else retries with exponential backoff: 30 s β†’ 1 min β†’ 2 min β†’ 4 min β†’ … capped at 24 h, up to 8 attempts. After that the event stays Failed.

Each delivery times out after 10 seconds.

Send test event (row β‹― menu) fires a synthetic webhook.test envelope synchronously and reports the response status in a toast.

// Test event envelope
{
  "eventType": "webhook.test",
  "occurredAt": "2026-05-04T17:30:00.123Z",
  "webhookId": 42,
  "agentId": 1,
  "workspaceId": 1,
  "data": {
    "message": "This is a test event from TeamSquared."
  }
}
test envelope

Manage via API

Same subscriptions as the dashboard, scoped to the token's agent. Requires webhooks:read / webhooks:write.

EndpointReturns
GET /webhooks{ webhooks: [...] } β€” never includes the secret.
POST /webhooks201 { webhook, secret }. Body: name, url (HTTPS), eventTypes, optional userTypes (default ["live"]) and viewId. 409 webhook_exists if an active subscription already has that URL.
GET /webhooks/:idOne subscription; 404 for another agent's id.
PATCH /webhooks/:idAny of name, url, eventTypes, userTypes, active, viewId (null clears).
DELETE /webhooks/:id204
POST /webhooks/:id/testFires a synthetic event synchronously; { ok, status, responseSnippet }.
ℹ️
viewId decides how data.user is projected in user.created / user.updated. It defaults to the creating token's view and must belong to the same agent.

Deploy scripts: GET first, then PATCH the existing subscription rather than POSTing a duplicate.

curl -X POST "https://api.teamsquared.ai/api/v1/webhooks" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM sync",
    "url": "https://hooks.example.com/teamsquared",
    "eventTypes": ["user.created", "user.updated", "user.unsubscribed"]
  }'
curl
{
  "webhook": {
    "id": 42,
    "name": "CRM sync",
    "url": "https://hooks.example.com/teamsquared",
    "eventTypes": ["user.created", "user.updated", "user.unsubscribed"],
    "userTypes": ["live"],
    "active": true,
    "viewId": 7,
    "createdAt": "2026-09-03T14:10:02.113Z",
    "updatedAt": "2026-09-03T14:10:02.113Z",
    "lastTriggeredAt": null,
    "lastFailureAt": null,
    "lastFailureMessage": null
  },
  "secret": "whsec_…"
}
201 response Β· secret shown once

Reference

Type definitions

User

type User = {
  // Always returned
  id: number;
  recipientId: string;
  externalId: string | null;  // your key, unique per agent
  createdAt: string;          // ISO 8601
  lastResponseAt: string | null;
  lastUpdatedAt: string;      // ISO 8601 β€” bumps on every write that changes the row
  archivedAt: string | null;
  doNotContact: boolean;

  // Additional fields exposed by the token's bound view.
  [key: string]: any;
};

type CreateUserRequest = {
  recipientId: string;
  externalId?: string | null;
  name?: string | null;
  lastResponseAt?: string;    // ISO 8601
  archivedAt?: string | null;
  doNotContact?: boolean;
  doNotContactReason?: string; // write-only, stored in history
  // Additional JSONB fields β€” must be in the bound view.
  // Internal columns (priority, reviewStatus, ...) return 400.
  [key: string]: any;
};

type UpdateUserRequest = Omit<CreateUserRequest, 'recipientId'>;

type BatchUpsertRequest = {
  matchOn?: 'recipientId' | 'externalId';   // default recipientId
  webhooks?: 'emit' | 'suppress';           // default emit
  users: CreateUserRequest[];               // 1–1,000
};

type BatchItemResult = {
  index: number;
  recipientId: string | null;
  externalId: string | null;
  id?: number;
  status: 'created' | 'updated' | 'unchanged' | 'error';
  changedFields?: string[];                 // on 'updated'
  user?: User;                              // with ?include=users
  error?: { code: string; message: string };
};

type BatchUpsertResponse = {
  summary: { received: number; created: number; updated: number; unchanged: number; failed: number };
  results: BatchItemResult[];
};

type Pagination = {
  limit: number;
  offset: number;
  hasMore: boolean;
  nextCursor?: string | null;               // sorts on lastUpdatedAt, createdAt, id
};
typescript

History

type HistoryEntry = {
  id: number;
  userId: number;
  event: string;
  message: string;
  createdAt: string;
};

type ChatMessage = {
  role: 'user' | 'assistant';
  content: string;
};
typescript

Schema

type UserSchemaField = {
  fieldName: string;
  type: 'string' | 'number' | 'boolean';
  enum?: string[];
  format?: string;            // e.g. 'date-time'
};
typescript

Webhooks

type WebhookSubscription = {
  id: number;
  name: string;
  url: string;
  eventTypes: string[];
  userTypes: string[];
  active: boolean;
  viewId: number | null;      // projects data.user in user.* events
  createdAt: string;
  updatedAt: string;
  lastTriggeredAt: string | null;
  lastFailureAt: string | null;
  lastFailureMessage: string | null;
};

type WebhookEnvelope = {
  eventType: string;
  occurredAt: string;
  webhookId: number;
  agentId: number;
  workspaceId: number;
  origin?: { kind: 'api'; tokenId: number; tokenPrefix: string };
  data: any;                  // user.created / user.updated: identifiers + changedFields + user
};
typescript

Filter operators

JSON arrays of { field, operator, value }. Caller filters combine via logicalOperator (default and), then AND with the view's filters.

OperatorUse
eqExact match. Strings case-sensitive.
neqNot an exact match. A row whose field is unset also matches; add is_not_null if you want only rows where the field is set.
likeCase-insensitive substring.
startsWithCase-insensitive match on the beginning of the value.
endsWithCase-insensitive match on the end of the value.
gtGreater than (numbers, dates).
ltLess than.
sinceβ‰₯ ISO timestamp. The updatedSince shortcut translates to this.
until≀ ISO timestamp.
inMatches any value in the array passed as value. A bare string counts as a one-element array; an empty array matches no rows.
is_nullField is NULL. value ignored.
is_not_nullField is not NULL. value ignored.
semantic_similarityMeaning-based match against an embedding-backed field; matches come back ordered by closeness to value. Only fields configured with embeddings support it β€” most do not.
⚠️
Those thirteen are the complete set. An operator name outside this list is rejected with 400 Bad Request, and so is an operator the particular field cannot support β€” for example gt on a boolean field. The request fails rather than quietly returning a wider result set. Names that look plausible but do not exist include equals, not_equals, contains, not_contains, not_like, gte, lte, between and not_in. Use since and until for inclusive range bounds, and like for substring matching.
[
  { "field": "status",         "operator": "eq",    "value": "active"          },
  { "field": "lastUpdatedAt",  "operator": "since", "value": "2026-05-01T00:00:00Z" },
  { "field": "priority",       "operator": "gt",    "value": 5                  }
]
filters Β· example

Changelog

VersionDateNotes
v1.1.02026-09-03Bulk upsert and upsert-by-address; externalId, archivedAt, doNotContact; DELETE /users/:userId (users:delete); cursor pagination; Idempotency-Key; rate-limit headers and a separate batch bucket; webhook subscriptions API; user.updated now carries changedFields and the projected user; user.unsubscribed / user.resubscribed.
v1.0.02026-03-09Initial release: users, history, schema, prompts.