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
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 20 | Number of records per page. Max 100. |
cursor | string | — | Pagination cursor returned as nextCursor from a previous response. |
filter_field | string | — | Field name to filter on (simple single-field filter). |
filter_operator | string | — | Filter operator. See operators below. |
filter_value | string | — | Value to filter against. Use comma-separated values for in. |
filters | string | — | JSON-encoded FilterGroup for complex filters with AND/OR nesting. |
sort_by | string | created_at | Field name to sort by. |
sort_dir | asc | desc | desc | Sort direction. |
sort | string | — | JSON array of [{field, direction}] for multi-column sorting. Takes precedence over sort_by / sort_dir. |
search | string | — | Full-text search query. Can be combined with filters. |
group_by | string | — | Activate grouped mode — returns { groups, counts } instead of the flat list. |
limit_per_group | number | 20 | Only 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
| Operator | Description | Example value |
|---|---|---|
equals | Exact match | "Lead" |
not_equals | Not equal | "archived" |
contains | Substring match (case-insensitive) | "marc" |
starts_with | Starts with | "Al" |
ends_with | Ends with | "tin" |
ilike | Case-insensitive LIKE pattern | "%example%" |
greater_than | Greater than | 100 |
less_than | Less than | 50 |
greater_equal | Greater than or equal | 10 |
less_equal | Less than or equal | 99 |
between | Between two values | "10,100" |
in | Matches any value in list | "Lead,customer,prospect" |
not_in | Matches none in list | "archived,deleted" |
is_null | Field is null | — |
is_not_null | Field is not null | — |
is_true | Boolean is true | — |
is_false | Boolean is false | — |
is_empty | Empty string or null | — |
is_not_empty | Not empty | — |
date_equals | Exact date match | "2025-01-15" |
date_before | Before date | "2025-06-01" |
date_after | After date | "2025-01-01" |
date_between | Between two dates | "2025-01-01,2025-12-31" |
date_today | Today's date | — |
date_this_week | Current week | — |
date_this_month | Current 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
| Parameter | Type | Default | Description |
|---|---|---|---|
group_by | string | — | Required 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_group | number | 20 | Max 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:
| Field | Description |
|---|---|
groups | Map 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. |
counts | Total 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 type | Key format | Example |
|---|---|---|
SELECT, RELATION, USERS, DEPENDENT_RELATION, PIPELINE | Raw value (or "uncategorized" for NULL) | "customer", "550e8400-...-000", "uncategorized" |
MULTI_SELECT | PostgreSQL array literal | "{champion}", "{champion,prospect}" |
BOOLEAN | "true" / "false" | "true" |
MORPH_RELATION | Coalesced 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:
ownerandcreated_byare 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:
| Key | Type | Description |
|---|---|---|
add_to_list_ids | string[] | Adds the record to these existing lists. Per-list outcome returned in lists. |
create_list_name | string | Creates 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 asPUT /api/v1/pipelines/{object}/{id}/moveand the kanban drag-drop. This includes:- Required-field validation of the source step's
required_for_next_stagequestions (forward) and source/target steps'required_for_closurequestions (closure towon/lost). The call fails withMISSING_REQUIRED_FIELDSand afield_errorsarray 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_atis stamped (drivespipeline_step_duration).
- Required-field validation of the source step's
- 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}/searchAccept 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
| Field | Type | Description |
|---|---|---|
filters | FilterGroup | JSON FilterGroup with AND/OR nesting (same structure as the filters query param). |
sort | array | Array of { field, direction } for multi-column sorting. direction defaults to desc. |
search | string | Full-text search query. Can be combined with filters. |
limit | number | Page size. Max 100. Defaults to 20. |
cursor | string | Pagination cursor (the nextCursor from a previous response). |
after | string | Alias 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
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_OBJECT | The {object} path segment is not a known object. |
| 400 | PERMISSION_DENIED | The caller lacks edit permission on the source record. |
| 400 | NO_RELATION | No many-to-many relation exists from {object} to {toObject}. |
Batch create
POST /api/v1/records/{object}/batch/createCreate 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):
| Key | Type | Description |
|---|---|---|
add_to_list_ids | string[] | Adds all created records to these existing lists. |
create_list_name | string | Creates 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/updateUpdate 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/upsertCreate 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.
idPropertymust 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
errorsarray — 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
}