KasarKasar Docs
API Reference

Records

Create, read, update, and delete records on any CRM object. Supports filtering, sorting, pagination, grouping, and full-text search.

The Records API lets you work with data on any object in your CRM — contacts, companies, opportunities, or any custom object you have defined. All endpoints use the object's API name (e.g., contacts, companies) as a path parameter.

List records

GET /api/v1/records/{object}

Returns a paginated list of records with optional filtering, sorting, and full-text search.

Query parameters

ParameterTypeDefaultDescription
limitnumber20Number of records per page. Max 100.
cursorstringPagination cursor returned as nextCursor from a previous response.
filter_fieldstringField name to filter on (simple single-field filter).
filter_operatorstringFilter operator. See operators below.
filter_valuestringValue to filter against. Use comma-separated values for in.
filtersstringJSON-encoded FilterGroup for complex filters with AND/OR nesting.
sort_bystringcreated_atField name to sort by.
sort_dirasc | descdescSort direction.
sortstringJSON array of [{field, direction}] for multi-column sorting. Takes precedence over sort_by / sort_dir.
searchstringFull-text search query. Can be combined with filters.
group_bystringActivate grouped mode — returns { groups, counts } instead of the flat list.
limit_per_groupnumber20Only used when group_by is set. Max rows returned per group. Max 100.

If both the simple filters (filter_field / filter_operator / filter_value) and the complex filters JSON are supplied, the filters JSON wins — the simple filter is ignored. Use one or the other.

Response

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "full_name": "Alice Martin",
      "email": "alice@example.com",
      "contact_status": "customer",
      "created_at": "2025-01-15T10:30:00Z"
    }
  ],
  "total": 142,
  "nextCursor": "eyJpZCI6IjU1MGU4NDAw..."
}

When nextCursor is absent or null, you have reached the last page.

Examples

# Basic list with limit
curl -X GET "https://kasar.app/api/v1/records/contacts?limit=10" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Simple filter
curl -X GET "https://kasar.app/api/v1/records/contacts?filter_field=contact_status&filter_operator=equals&filter_value=Lead&limit=50" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Pagination with cursor
curl -X GET "https://kasar.app/api/v1/records/contacts?cursor=eyJpZCI6IjU1MGU4NDAw...&limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Sorting
curl -X GET "https://kasar.app/api/v1/records/contacts?sort_by=full_name&sort_dir=asc" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Full-text search
curl -X GET "https://kasar.app/api/v1/records/contacts?search=martin" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
const baseUrl = "https://kasar.app/api/v1";
const headers = { Authorization: "Bearer YOUR_API_TOKEN" };

// Basic list with limit
const response = await fetch(`${baseUrl}/records/contacts?limit=10`, { headers });
const { data, total, nextCursor } = await response.json();

// Simple filter
const leads = await fetch(
  `${baseUrl}/records/contacts?filter_field=contact_status&filter_operator=equals&filter_value=Lead`,
  { headers }
).then((r) => r.json());

// Paginate through all results
let cursor: string | undefined;
const allRecords = [];

do {
  const url = new URL(`${baseUrl}/records/contacts`);
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("cursor", cursor);

  const page = await fetch(url.toString(), { headers }).then((r) => r.json());
  allRecords.push(...page.data);
  cursor = page.nextCursor;
} while (cursor);

Complex filters

For advanced filtering with AND/OR logic, pass a JSON-encoded FilterGroup as the filters query parameter.

FilterGroup structure:

type FilterGroup = {
  type: "AND" | "OR";
  conditions: Array<FilterCondition | FilterGroup>;
};

type FilterCondition = {
  field: string;
  operator: string;
  value: string | number | boolean | null;
};

Example — contacts named "marc" who are either leads or customers:

{
  "type": "AND",
  "conditions": [
    { "field": "full_name", "operator": "contains", "value": "marc" },
    {
      "type": "OR",
      "conditions": [
        { "field": "contact_status", "operator": "equals", "value": "Lead" },
        { "field": "contact_status", "operator": "equals", "value": "customer" }
      ]
    }
  ]
}
# URL-encode the JSON filter
curl -G "https://kasar.app/api/v1/records/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  --data-urlencode 'filters={"type":"AND","conditions":[{"field":"full_name","operator":"contains","value":"marc"},{"type":"OR","conditions":[{"field":"contact_status","operator":"equals","value":"Lead"},{"field":"contact_status","operator":"equals","value":"customer"}]}]}'
const filters = {
  type: "AND",
  conditions: [
    { field: "full_name", operator: "contains", value: "marc" },
    {
      type: "OR",
      conditions: [
        { field: "contact_status", operator: "equals", value: "Lead" },
        { field: "contact_status", operator: "equals", value: "customer" },
      ],
    },
  ],
};

const url = new URL(`${baseUrl}/records/contacts`);
url.searchParams.set("filters", JSON.stringify(filters));

const response = await fetch(url.toString(), { headers });

Filter operators

OperatorDescriptionExample value
equalsExact match"Lead"
not_equalsNot equal"archived"
containsSubstring match (case-insensitive)"marc"
starts_withStarts with"Al"
ends_withEnds with"tin"
ilikeCase-insensitive LIKE pattern"%example%"
greater_thanGreater than100
less_thanLess than50
greater_equalGreater than or equal10
less_equalLess than or equal99
betweenBetween two values"10,100"
inMatches any value in list"Lead,customer,prospect"
not_inMatches none in list"archived,deleted"
is_nullField is null
is_not_nullField is not null
is_trueBoolean is true
is_falseBoolean is false
is_emptyEmpty string or null
is_not_emptyNot empty
date_equalsExact date match"2025-01-15"
date_beforeBefore date"2025-06-01"
date_afterAfter date"2025-01-01"
date_betweenBetween two dates"2025-01-01,2025-12-31"
date_todayToday's date
date_this_weekCurrent week
date_this_monthCurrent month

Operators like is_null, is_true, date_today, date_this_week, and date_this_month do not require a value.


Grouped mode (top-N per group)

When you need records grouped by a field value — kanban-style columns, category breakdowns, "top 5 deals per pipeline step" — pass group_by to the list endpoint. The response shape changes to a dictionary of groups instead of a flat list.

Under the hood the API executes a single ROW_NUMBER() OVER (PARTITION BY <field>) query, so each group is guaranteed to receive up to limit_per_group rows regardless of data distribution. One round-trip, no client-side stitching.

GET /api/v1/records/{object}?group_by=<field>&limit_per_group=<n>

Query parameters specific to grouped mode

ParameterTypeDefaultDescription
group_bystringRequired to activate grouped mode. Logical field name, or for PIPELINE fields you may also pass the step column name (e.g. pipeline_step_id).
limit_per_groupnumber20Max rows per group. Clamped to [1, 100].

Shared query parameters

The following behave the same as in the flat list mode: filters, filter_field / filter_operator / filter_value, search, sort, sort_by / sort_dir. The sort is intra-group — it controls which rows "win" the top-limit_per_group slots inside each group.

cursor is ignored in grouped mode. To page deeper into a single group, request a larger limit_per_group or issue a second list call with filter_field=<group_by>&filter_value=<group_key>.

Response

{
  "object_name": "opportunities",
  "group_by": "pipeline_step_id",
  "limit_per_group": 10,
  "groups": {
    "step-uuid-qualified": [
      { "id": "...", "name": "Acme Q1 renewal", "amount": 50000, "pipeline_step_id": "step-uuid-qualified" },
      { "id": "...", "name": "Globex expansion", "amount": 120000, "pipeline_step_id": "step-uuid-qualified" }
    ],
    "step-uuid-proposal": [
      { "id": "...", "name": "Initech Q2", "amount": 80000, "pipeline_step_id": "step-uuid-proposal" }
    ],
    "uncategorized": []
  },
  "counts": {
    "step-uuid-qualified": 42,
    "step-uuid-proposal": 17,
    "step-uuid-negotiation": 8,
    "uncategorized": 3
  }
}

Fields:

FieldDescription
groupsMap of groupKey → Row[]. Rows carry the same enriched shape as the flat list ({fieldName}_display for relations, composite fields expanded, etc.). Each group contains at most limit_per_group rows.
countsTotal number of rows per group (not limited). Use this to display pagination hints ("counts[key] > groups[key].length ⇒ more rows exist"). Totals respect the same filters/search/visibility as the grouped data.

Group keys

The key format depends on the type of the group_by field:

Field typeKey formatExample
SELECT, RELATION, USERS, DEPENDENT_RELATION, PIPELINERaw value (or "uncategorized" for NULL)"customer", "550e8400-...-000", "uncategorized"
MULTI_SELECTPostgreSQL array literal"{champion}", "{champion,prospect}"
BOOLEAN"true" / "false""true"
MORPH_RELATIONCoalesced value across morph columns"550e8400-...", "uncategorized"

Examples

# Top 10 opportunities per pipeline step
curl -G "https://kasar.app/api/v1/records/opportunities" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  --data-urlencode "group_by=pipeline_step_id" \
  --data-urlencode "limit_per_group=10"

# Top 5 contacts per status, sorted alphabetically within each group
curl -G "https://kasar.app/api/v1/records/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  --data-urlencode "group_by=contact_status" \
  --data-urlencode "limit_per_group=5" \
  --data-urlencode 'sort=[{"field":"full_name","direction":"asc"}]'

# Grouped with filter — only "marc" contacts, partitioned by status
curl -G "https://kasar.app/api/v1/records/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  --data-urlencode "group_by=contact_status" \
  --data-urlencode "filter_field=full_name" \
  --data-urlencode "filter_operator=contains" \
  --data-urlencode "filter_value=marc"
// Top 10 opportunities per pipeline step, rendered as a kanban
const url = new URL(`${baseUrl}/records/opportunities`);
url.searchParams.set("group_by", "pipeline_step_id");
url.searchParams.set("limit_per_group", "10");

const response = await fetch(url.toString(), { headers });
const { groups, counts } = await response.json();

// Render each column
for (const [stepId, rows] of Object.entries(groups)) {
  const total = counts[stepId];
  const hasMore = total > rows.length;
  renderColumn(stepId, rows, { total, hasMore });
}

// Intra-group sort — rows within each status ordered by full_name ASC
const sortedUrl = new URL(`${baseUrl}/records/contacts`);
sortedUrl.searchParams.set("group_by", "contact_status");
sortedUrl.searchParams.set("limit_per_group", "5");
sortedUrl.searchParams.set("sort", JSON.stringify([
  { field: "full_name", direction: "asc" },
]));

const sorted = await fetch(sortedUrl.toString(), { headers }).then(r => r.json());

Paging beyond limit_per_group

Grouped mode is intended for the initial "top-N per column" view. When a user scrolls inside a specific group, fetch more rows with a follow-up flat-list call filtered on that group's value:

# Load rows 21-40 of the "customer" bucket
curl -G "https://kasar.app/api/v1/records/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  --data-urlencode "filter_field=contact_status" \
  --data-urlencode "filter_operator=equals" \
  --data-urlencode "filter_value=customer" \
  --data-urlencode "limit=20" \
  --data-urlencode "cursor=<from-previous-call>"

The limit_per_group echoed in the response is exactly the value you requested (clamped to [1, 100]). Use counts to detect when a group holds more rows than were returned (counts[key] > groups[key].length).


Get a record

GET /api/v1/records/{object}/{id}

Returns a single record by ID with enriched data, including:

  • Display values for foreign key relations (e.g., company name instead of just the ID)
  • Compound fields expanded (EMAILS, PHONES, ADDRESS)
  • Many-to-many relations included
  • List memberships (lists): every list containing the record, with the values of the list's per-entry fields

Response

{
  "record": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "full_name": "Alice Martin",
    "email": "alice@example.com",
    "contact_status": "customer",
    "company_id": "7a2b3c4d-e5f6-7890-abcd-ef1234567890",
    "company_id_display": "Acme Corp",
    "emails": [
      { "email": "alice@example.com", "label": "work", "is_primary": true },
      { "email": "alice.m@gmail.com", "label": "personal", "is_primary": false }
    ],
    "phones": [
      { "phone": "+33612345678", "label": "mobile", "is_primary": true }
    ],
    "tags": ["vip", "enterprise"],
    "created_at": "2025-01-15T10:30:00Z",
    "updated_at": "2025-03-20T14:15:00Z"
  },
  "lists": [
    {
      "list_id": "a529e4c5-3d2e-4699-8daa-144a8b1db154",
      "list_name": "Salon 2026",
      "entry_id": "f3dcfe67-a2c7-4753-8743-c8fae7291614",
      "entry_fields": { "added_at": "2026-06-11T16:57:09Z", "statut_revue": "valide" }
    }
  ]
}

The single record is returned under the top-level record key (not data). lists is omitted when the record belongs to no list. entry_fields carries the list's per-entry custom fields (review status, score, notes added on the list itself).

Examples

curl -X GET "https://kasar.app/api/v1/records/contacts/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
const recordId = "550e8400-e29b-41d4-a716-446655440000";

const response = await fetch(`${baseUrl}/records/contacts/${recordId}`, {
  headers,
});
const { record, lists } = await response.json();

Create a record

POST /api/v1/records/{object}

Creates a new record on the specified object. The request body contains field values as a JSON object.

Automatic behavior

  • Pipeline defaults: If the object has a pipeline, the default pipeline and its first step are auto-assigned.
  • User fields: owner and created_by are set to the authenticated user.
  • Field defaults: Default values from field metadata are applied for any omitted fields.

Request body

{
  "first_name": "Alice",
  "last_name": "Martin",
  "email": "alice@example.com",
  "company_id": "7a2b3c4d-e5f6-7890-abcd-ef1234567890",
  "contact_status": "Lead"
}

Adding to lists in the same call

Two reserved top-level keys (not record fields) let you place the record into lists at creation time:

KeyTypeDescription
add_to_list_idsstring[]Adds the record to these existing lists. Per-list outcome returned in lists.
create_list_namestringCreates a new list with this name (allowing this object) and adds the record to it. Admin only.
{
  "first_name": "Paul",
  "last_name": "Durand",
  "add_to_list_ids": ["a529e4c5-3d2e-4699-8daa-144a8b1db154"],
  "create_list_name": "Salon 2026"
}

The response then includes lists (per-list outcome) and created_list when a new list was created. A failed list addition never rolls back the record write. Both keys are also accepted on PUT /records/{object}/{id} and POST /records/{object}/batch/create (top-level, applied to every created record).

Response

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "action": "created",
  "record": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "first_name": "Alice",
    "last_name": "Martin",
    "email": "alice@example.com",
    "company_id": "7a2b3c4d-e5f6-7890-abcd-ef1234567890",
    "contact_status": "Lead",
    "owner": "user-uuid",
    "created_by": "user-uuid",
    "pipeline_id": "default-pipeline-uuid",
    "pipeline_step_id": "first-step-uuid",
    "created_at": "2025-01-15T10:30:00Z",
    "updated_at": "2025-01-15T10:30:00Z"
  }
}

Error responses

All write errors are returned with HTTP 400 and a flat error body ({ code, message, ... }) — there is no "error": true envelope.

Duplicate record:

{
  "code": "DUPLICATE_RECORD",
  "message": "A contact with this email already exists"
}

Validation error (carries a per-field field_errors array):

{
  "code": "VALIDATION_ERROR",
  "message": "Validation failed",
  "field_errors": [
    { "field": "email", "message": "Must be a valid email address" },
    { "field": "first_name", "message": "This field is required" }
  ]
}

Examples

curl -X POST "https://kasar.app/api/v1/records/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Alice",
    "last_name": "Martin",
    "email": "alice@example.com",
    "company_id": "7a2b3c4d-e5f6-7890-abcd-ef1234567890",
    "contact_status": "Lead"
  }'
const response = await fetch(`${baseUrl}/records/contacts`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    first_name: "Alice",
    last_name: "Martin",
    email: "alice@example.com",
    company_id: "7a2b3c4d-e5f6-7890-abcd-ef1234567890",
    contact_status: "Lead",
  }),
});

const result = await response.json();

if (!response.ok) {
  if (result.code === "DUPLICATE_RECORD") {
    console.error("Duplicate detected:", result.message);
  } else if (result.code === "VALIDATION_ERROR") {
    console.error("Validation errors:", result.field_errors);
  }
} else {
  console.log("Created record:", result.id);
}

Update a record

PUT /api/v1/records/{object}/{id}

Updates an existing record. Only include the fields you want to change — omitted fields are left unchanged.

Automatic behavior

  • Pipeline transitions: If the pipeline_step_id (or any PIPELINE step column) changes, the endpoint runs the canonical pipeline transition flow — same code path as PUT /api/v1/pipelines/{object}/{id}/move and the kanban drag-drop. This includes:
    • Required-field validation of the source step's required_for_next_stage questions (forward) and source/target steps' required_for_closure questions (closure to won/lost). The call fails with MISSING_REQUIRED_FIELDS and a field_errors array if any linked field is empty. A single payload that fills the required field AND changes the step passes validation. Backward transitions skip validation.
    • Forced and default values configured on the target step are applied server-side.
    • Witness rows are recorded in pipeline_step_history (skipped on backward transitions).
    • pipeline_step_entered_at is stamped (drives pipeline_step_duration).
  • Activity log: A step change entry is recorded.
  • Enriched response: The returned record includes display values for relations, just like the GET endpoint.

Request body

{
  "contact_status": "customer",
  "email": "alice.martin@newdomain.com"
}

Step transition example:

{
  "budget": 50000,
  "decision_maker": "Alice Martin",
  "pipeline_step_id": "uuid-proposal-step"
}

To inspect required fields per step before updating, use GET /api/v1/pipelines (or the kasar://pipelines MCP resource), which lists every question with its linked_field_name, required_for_next_stage, required_for_closure, and forced_value flags.

MISSING_REQUIRED_FIELDS error

Returned with HTTP 400 as a flat error body:

{
  "code": "MISSING_REQUIRED_FIELDS",
  "message": "MISSING_REQUIRED_FIELDS: cannot transition step — fill these fields first: budget, decision_maker",
  "field_errors": [
    { "field": "budget", "message": "Budget is required (required_for_next_stage)" },
    { "field": "decision_maker", "message": "Decision maker is required (required_for_next_stage)" }
  ]
}

Response

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "action": "updated",
  "record": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "first_name": "Alice",
    "last_name": "Martin",
    "email": "alice.martin@newdomain.com",
    "contact_status": "customer",
    "company_id": "7a2b3c4d-e5f6-7890-abcd-ef1234567890",
    "company_id_display": "Acme Corp",
    "updated_at": "2025-03-20T14:15:00Z"
  }
}

Examples

curl -X PUT "https://kasar.app/api/v1/records/contacts/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_status": "customer",
    "email": "alice.martin@newdomain.com"
  }'
const recordId = "550e8400-e29b-41d4-a716-446655440000";

const response = await fetch(`${baseUrl}/records/contacts/${recordId}`, {
  method: "PUT",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    contact_status: "customer",
    email: "alice.martin@newdomain.com",
  }),
});

const { id, action, record } = await response.json();

Delete records

DELETE /api/v1/records/{object}

Deletes one or more records in a single request. Deletion is subject to per-record RBAC checks — if the authenticated user does not have permission to delete a specific record, that record is skipped and reported in the errors array.

For contacts with synced email accounts, the sync state is cleaned up automatically.

Request body

{
  "record_ids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "661f9511-f30c-52e5-b827-557766551111"
  ]
}

Response

{
  "deleted": 2,
  "deleted_ids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "661f9511-f30c-52e5-b827-557766551111"
  ],
  "failed": 0
}

deleted is the count, deleted_ids lists the IDs actually removed, and failed is the count of records that could not be deleted. errors is present only when failed > 0.

Partial failure:

{
  "deleted": 1,
  "deleted_ids": ["550e8400-e29b-41d4-a716-446655440000"],
  "failed": 1,
  "errors": [
    {
      "id": "661f9511-f30c-52e5-b827-557766551111",
      "reason": "Permission denied"
    }
  ]
}

Examples

curl -X DELETE "https://kasar.app/api/v1/records/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "record_ids": [
      "550e8400-e29b-41d4-a716-446655440000",
      "661f9511-f30c-52e5-b827-557766551111"
    ]
  }'
const response = await fetch(`${baseUrl}/records/contacts`, {
  method: "DELETE",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    record_ids: [
      "550e8400-e29b-41d4-a716-446655440000",
      "661f9511-f30c-52e5-b827-557766551111",
    ],
  }),
});

const { deleted, failed, errors } = await response.json();

if (failed > 0) {
  console.warn(`${failed} records could not be deleted:`, errors);
}

Deletion is permanent. There is no soft-delete or trash. Make sure to confirm the operation before calling this endpoint in user-facing applications.


Delete a single record

DELETE /api/v1/records/{object}/{id}

Deletes a single record by ID. This is a convenience alternative to the batch delete endpoint above — internally it calls the same handler with a single-element record_ids array, so the response shape is identical.

Response

{
  "deleted": 1,
  "deleted_ids": ["550e8400-e29b-41d4-a716-446655440000"],
  "failed": 0
}

Update a record (PATCH)

PATCH /api/v1/records/{object}/{id}

PATCH is an alias of PUT — both perform partial updates. Only include the fields you want to change; omitted fields are left unchanged.


Search records

POST /api/v1/records/{object}/search

Accept a FilterGroup and sorting in the request body instead of query parameters. This is cleaner for complex filters that would be awkward to URL-encode.

Request body

FieldTypeDescription
filtersFilterGroupJSON FilterGroup with AND/OR nesting (same structure as the filters query param).
sortarrayArray of { field, direction } for multi-column sorting. direction defaults to desc.
searchstringFull-text search query. Can be combined with filters.
limitnumberPage size. Max 100. Defaults to 20.
cursorstringPagination cursor (the nextCursor from a previous response).
afterstringAlias for cursor — accepted interchangeably.
{
  "filters": {
    "type": "AND",
    "conditions": [
      { "field": "contact_status", "operator": "equals", "value": "Lead" },
      { "field": "full_name", "operator": "contains", "value": "marc" }
    ]
  },
  "sort": [{ "field": "created_at", "direction": "desc" }],
  "limit": 20,
  "after": "eyJ..."
}

The response format is the same as the List records endpoint: { data, total, nextCursor }. Field projection is not supported — every enriched field is returned.


Associations

PUT /api/v1/records/{object}/{id}/associations/{toObject}/{toId}
DELETE /api/v1/records/{object}/{id}/associations/{toObject}/{toId}

Explicitly manage many-to-many relationships between records.

Example

Link a contact to a company:

curl -X PUT "https://kasar.app/api/v1/records/contacts/UUID/associations/companies/UUID2" \
  -H "Authorization: Bearer ksr_..."

Request body (optional)

Pass junction field data in the request body:

{
  "position": "CEO & Chairman"
}

The available junction fields depend on the M2M relation configuration.

Response (PUT)

The type is formatted as {object}_to_{toObject}. The junctionFields array lists the available junction fields for discoverability:

{
  "from": "UUID",
  "to": "UUID2",
  "type": "contacts_to_companies",
  "action": "created",
  "junctionFields": [
    { "name": "position", "type": "TEXT", "label": "Poste" }
  ]
}

junctionFields is omitted when the relation has no junction fields configured.

Response (DELETE)

To remove the association, use the same path with DELETE. The response omits type and junctionFields:

{
  "from": "UUID",
  "to": "UUID2",
  "action": "deleted"
}

Error responses

StatusCodeDescription
400INVALID_OBJECTThe {object} path segment is not a known object.
400PERMISSION_DENIEDThe caller lacks edit permission on the source record.
400NO_RELATIONNo many-to-many relation exists from {object} to {toObject}.

Batch create

POST /api/v1/records/{object}/batch/create

Create multiple records in a single request. Maximum 100 records per call. Each element may be { "data": { ... } } or the field object directly.

Request body

{
  "inputs": [
    { "data": { "first_name": "Alice", "last_name": "Martin", "email": "alice@example.com" } },
    { "data": { "first_name": "Bob", "last_name": "Dupont", "email": "bob@example.com" } }
  ]
}

You may also place every created record into one or more lists in the same call, with two reserved top-level keys (applied to all records created by the batch):

KeyTypeDescription
add_to_list_idsstring[]Adds all created records to these existing lists.
create_list_namestringCreates a new list with this name (allowing this object) and adds all created records to it. Admin only.

Response

results holds the per-record { id, action, record } outcome; errors holds { index, code, message } for any failed element.

{
  "results": [ { "id": "...", "action": "created", "record": { } } ],
  "errors": [],
  "total": 2,
  "created": 2,
  "failed": 0
}

When add_to_list_ids / create_list_name is supplied, the response also includes a top-level lists array (per-list outcome) and created_list when a new list was created.


Batch update

POST /api/v1/records/{object}/batch/update

Update multiple records in a single request. Maximum 100 records per call. Each element must carry an id; elements missing one are reported as MISSING_REQUIRED_FIELD in errors.

Request body

{
  "inputs": [
    { "id": "550e8400-e29b-41d4-a716-446655440000", "data": { "contact_status": "customer" } },
    { "id": "661f9511-f30c-52e5-b827-557766551111", "data": { "contact_status": "Lead" } }
  ]
}

Response

The success counter is updated (not created):

{
  "results": [ { "id": "...", "action": "updated", "record": { } } ],
  "errors": [],
  "total": 2,
  "updated": 2,
  "failed": 0
}

Batch update does not accept the add_to_list_ids / create_list_name list keys — use batch create, batch upsert, or PUT /records/{object}/{id} for those.


Batch upsert

POST /api/v1/records/{object}/batch/upsert

Create or update records in a single request. Maximum 100 records per call. Each record is looked up by idProperty — if found, it is updated; if not, it is created.

  • idProperty must be a field that uniquely identifies records (e.g. email, linkedin_url, name).
  • If the lookup finds multiple matches, the first matching record is updated.
  • Partial failures are reported per-record in the errors array — successful records in the same batch are still committed.

Request body

idProperty is required. Each element may be { "data": { ... } } or the field object directly, and the idProperty value must be present and non-empty on every element (otherwise that element is reported in errors).

{
  "inputs": [
    { "data": { "email": "alice@example.com", "first_name": "Alice", "contact_status": "customer" } },
    { "data": { "email": "new@example.com", "first_name": "New Contact", "contact_status": "Lead" } }
  ],
  "idProperty": "email"
}

The optional add_to_list_ids / create_list_name top-level keys (see Batch create) also work here — they apply to every touched record (both created and updated).

Response

The response reports created and updated separately. results is the concatenation of created then updated records:

{
  "results": [ { "id": "...", "action": "created", "record": { } } ],
  "errors": [],
  "total": 2,
  "created": 1,
  "updated": 1,
  "failed": 0
}

On this page