API Β· v1

API Reference

Users, history, schema, prompts, and versioned KV.

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, append history.POST /users, PATCH /users/:userId, POST /users/:userId/history
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.
kv:readRead KV.GET /kv, GET /kv/:key
kv:writeWrite/delete KV.PUT /kv/:key, DELETE /kv/:key
{
  "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.

Apprentice 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, createdAt, lastResponseAt, lastUpdatedAt.
  • 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",
      "createdAt": "2025-09-14T08:12:04.118Z",
      "lastResponseAt": "2026-04-29T17:05:22.901Z",
      "lastUpdatedAt": "2026-04-30T11:18:43.220Z",
      "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
  }
}
200 response

Detecting changes

lastUpdatedAt bumps on every mutation. Two patterns:

  • Poll-and-diff β€” track the last lastUpdatedAt per user; skip when unchanged.
  • Incremental sync β€” pass ?updatedSince=<iso>, then save the max lastUpdatedAt from the batch for next loop.
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 "limit=200" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl Β· incremental sync

Rate limiting

60 req/min/token, fixed-window. Per-token, not per-IP.

On exceed: 429 with Retry-After. Please honor it.

HTTP/1.1 429 Too Many Requests
Retry-After: 47
Content-Type: application/json

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

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 (e.g. existing recipientId)
429Too Many RequestsRate limit β€” see Rate limiting
500Internal Server ErrorServer error

JSON body with error, often message, 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 (no by-ID route).

ℹ️
recipientId is the primary channel address (phone, WhatsApp, etc.) β€” a cross-platform customer key. Unique per agent. Inbound webhooks resolve against it.

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.

Query parameters

ParameterTypeDescription
limitnumberDefault 50, max 200 (clamped).
offsetnumberDefault 0. Must be a multiple of limit.
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.

Errors

  • 400 Filter field not in view: <field>
  • 400 offset must be a multiple of limit
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",
      "createdAt": "2025-10-31T02:37:58.753Z",
      "lastResponseAt": null,
      "lastUpdatedAt": "2026-05-01T10:42:11.005Z",
      "name": "John Doe",
      "appointment_time": "2026-05-02T10:00:00Z"
    }
  ],
  "totalCount": 137,
  "pagination": {
    "limit": 50,
    "offset": 0,
    "hasMore": true
  }
}
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.
namestringDisplay name.
lastResponseAtISO 8601Seed the inferred last-response timestamp.
[any other key]anyCustom data. Must be in the bound view.

Errors

  • 400 recipientId missing
  • 409 User with that recipientId exists
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.
[any other key]anyMerged into custom data. Must be in the bound view.
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

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, e.g. user_message, appointment_scheduled.
messagereqstringHuman-readable summary.
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
{
  "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_message" \
  --data-urlencode "sinceSeconds=86400" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "history": [
    {
      "id": 1172,
      "userId": 137,
      "event": "user_message",
      "message": "Hello, I need help",
      "createdAt": "2025-10-31T02:35:50.315Z"
    },
    {
      "id": 1173,
      "userId": 137,
      "event": "assistant_message",
      "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.
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

KV storage

Version-scoped key/value store. Defaults to the live version; pass ?versionId=<id> to target another.

List KV pairs

GET /api/v1/kv

All KV pairs for the version.

Required scope kv:read

ParameterTypeDescription
versionIdnumberDefaults to live.
curl "https://api.teamsquared.ai/api/v1/kv" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "entries": [
    {
      "key": "apiKey",
      "value": "sk-...",
      "createdAt": "2025-12-01T10:00:00.000Z",
      "updatedAt": "2025-12-01T10:00:00.000Z"
    },
    {
      "key": "debugMode",
      "value": true,
      "createdAt": "2025-12-01T10:00:00.000Z",
      "updatedAt": "2025-12-01T10:00:00.000Z"
    }
  ]
}
200 response

Get value

GET /api/v1/kv/:key

URL-encode the key.

Required scope kv:read

curl "https://api.teamsquared.ai/api/v1/kv/apiKey" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "key": "apiKey",
  "value": "sk-...",
  "createdAt": "2025-12-01T10:00:00.000Z",
  "updatedAt": "2025-12-01T10:00:00.000Z"
}
200 response

Set value

PUT /api/v1/kv/:key

Upsert. Keys ≀ 256 chars; values any JSON.

Required scope kv:write

curl -X PUT "https://api.teamsquared.ai/api/v1/kv/debugMode" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{"value": true}'
curl
{
  "success": true,
  "key": "debugMode"
}
200 response

Delete value

DELETE /api/v1/kv/:key

404 if missing.

Required scope kv:write

curl -X DELETE "https://api.teamsquared.ai/api/v1/kv/debugMode" \
  -H "Authorization: Bearer ext_agent_REPLACE_ME"
curl
{
  "success": true,
  "key": "debugMode"
}
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), not in the API.

πŸ’‘
Pair with the API. Webhooks tell you that something changed; the API gives you the current state. Common pattern: handler fetches the affected user via GET /users and writes it to your system.
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 field on a user changes.
user.deletedA user is deleted.
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.

data is intentionally minimal β€” fetch full state from the API if you need it.

{
  "eventType": "user.updated",
  "occurredAt": "2026-05-04T17:30:00.123Z",
  "webhookId": 42,
  "agentId": 1,
  "workspaceId": 1,
  "data": {
    "id": 137,
    "recipientId": "+1234567890",
    "userType": "live",
    "createdAt": "2025-10-31T02:37:58.753Z",
    "lastUpdatedAt": "2026-05-04T17:30:00.005Z"
  }
}
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

Reference

Type definitions

User

type User = {
  // Always returned
  id: number;
  recipientId: string;
  createdAt: string;          // ISO 8601
  lastResponseAt: string | null;
  lastUpdatedAt: string;      // ISO 8601 β€” bumps on every write

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

type CreateUserRequest = {
  recipientId: string;
  name?: string | null;
  lastResponseAt?: string;    // ISO 8601
  // Additional JSONB fields β€” must be in the bound view.
  // Internal columns (status, priority, userType, ...) return 400.
  [key: string]: any;
};

type UpdateUserRequest = {
  name?: string | null;
  lastResponseAt?: string;    // ISO 8601
  [key: string]: any;
};
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

KV

type KVEntry = {
  key: string;
  value: any;                 // Any JSON-serializable value
  createdAt: string;
  updatedAt: string;
};

type SetKVRequest    = { value: any };
type KVOperationResponse = { success: boolean; key: string };
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.
likeCase-insensitive substring.
gtGreater than (numbers, dates).
ltLess than.
sinceβ‰₯ ISO timestamp. The updatedSince shortcut translates to this.
until≀ ISO timestamp.
is_nullField is NULL. value ignored.
is_not_nullField is not NULL. value ignored.
[
  { "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.0.02026-03-09Initial release: users, history, schema, prompts, KV.